From b48ac91fec199cafbf9bf872825fad0868e64b02 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 10 Jul 2026 09:42:28 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=BF=8C=EB=A6=AC=EC=98=A4=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=95=20=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EA=B3=A8?= =?UTF-8?q?=EA=B2=A9=20+=20=ED=99=98=EA=B2=BD=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 — 플러그인 설치·활성화 시 권한·메뉴·환경설정 화면이 뜨는 기반 구축. 이후 발송엔진·채널·이력·템플릿 Phase 의 전제가 된다. - 관리자 환경설정 화면(3섹션: API 연동/발송 설정/연동 안내) — 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영). 크리덴셜은 마스킹 + 프론트 미노출. - 조회/관리 권한 2개 + 「메시지 발송 관리」 관리자 메뉴(알림톡 템플릿·발송 이력 하위). - 잔액 부족 시 관리자 알림 정의(webhook 결과코드 감지는 Phase 4). - 발송 관련 Enum 4종(채널/상태/출처/결과분류) — Phase 2~4 소비용 골격. 알림톡 채널 기본 body 시드는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이라 Phase 6 으로 이관(plugin.php 주석 명시). 테스트: PHPUnit 12건(설치·권한·메뉴·알림 정의·Enum) + Vitest 22건(환경설정 레이아웃). 설치→활성화 실검증으로 권한 4·메뉴 3·알림 1·설정 초기값 로드 확인. --- .../sirsoft-message_bizppurio/CHANGELOG.md | 13 + .../sirsoft-message_bizppurio/LICENSE | 47 ++ .../sirsoft-message_bizppurio/composer.json | 22 + .../config/settings/defaults.json | 22 + .../lang/en/messages.php | 33 + .../lang/ko/messages.php | 33 + .../sirsoft-message_bizppurio/package.json | 16 + .../sirsoft-message_bizppurio/plugin.json | 25 + .../sirsoft-message_bizppurio/plugin.php | 367 ++++++++++ .../resources/js/__tests__/layouts/helpers.ts | 120 ++++ .../layouts/plugin_settings.test.tsx | 154 +++++ .../resources/lang/en.json | 60 ++ .../resources/lang/ko.json | 60 ++ .../layouts/admin/plugin_settings.json | 644 ++++++++++++++++++ .../src/Enums/DispatchChannel.php | 52 ++ .../src/Enums/DispatchSource.php | 42 ++ .../src/Enums/DispatchStatus.php | 54 ++ .../src/Enums/ResultCategory.php | 69 ++ .../MessageBizppurioServiceProvider.php | 58 ++ .../Feature/Installation/InstallationTest.php | 140 ++++ .../tests/PluginTestCase.php | 107 +++ .../tests/Unit/Enums/EnumTest.php | 64 ++ .../tests/scenarios/plugin-settings.yaml | 66 ++ .../sirsoft-message_bizppurio/tsconfig.json | 17 + .../vitest.config.ts | 16 + 25 files changed, 2301 insertions(+) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/LICENSE create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/composer.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/package.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/plugin.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/plugin.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md new file mode 100644 index 00000000..69d4b516 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +이 프로젝트의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.0] - 2026-07-09 + +### Added + +- 비즈뿌리오(다우기술) 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. +- 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다. +- 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/LICENSE b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE new file mode 100644 index 00000000..4e94ed38 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE @@ -0,0 +1,47 @@ +프로그램 명칭 : 그누보드7용 비즈뿌리오 메시징 플러그인 (sirsoft-message_bizppurio) + +저작자 : (주)에스아이알소프트 + +----- MIT 라이선스 (한국어 번역) -------------------------------------------------------- + +MIT 라이선스 + +Copyright (c) 2026 (주)에스아이알소프트 + +이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게 +소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는 +판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음 +조건을 따라야 합니다: + +위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야 +합니다. + +소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지 +않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에 +국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의 +사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타 +책임에 대해 책임을 지지 않습니다. + +----- MIT License (English Original) -------------------------------------------------------- + +The MIT License (MIT) + +Copyright (c) 2026 SIRSOFT + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/composer.json b/plugins/_bundled/sirsoft-message_bizppurio/composer.json new file mode 100644 index 00000000..22992fd2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/composer.json @@ -0,0 +1,22 @@ +{ + "name": "plugins/sirsoft-message_bizppurio", + "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft", + "type": "library", + "version": "1.0.0", + "license": "MIT", + "authors": [ + { + "name": "sirsoft", + "email": "contact@sirsoft.com" + } + ], + "require": { + "php": "^8.2", + "ext-json": "*" + }, + "autoload": { + "psr-4": { + "Plugins\\Sirsoft\\MessageBizppurio\\": ["src/", "./"] + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json new file mode 100644 index 00000000..c77a63f4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json @@ -0,0 +1,22 @@ +{ + "_meta": { + "version": "1.0.0", + "description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false." + }, + "defaults": { + "environment": "dev", + "bizppurio_id": "", + "password": "", + "api_key": "", + "sender_number": "", + "sender_key": "" + }, + "frontend_schema": { + "environment": { "expose": false }, + "bizppurio_id": { "expose": false }, + "password": { "expose": false, "sensitive": true }, + "api_key": { "expose": false, "sensitive": true }, + "sender_number": { "expose": false }, + "sender_key": { "expose": false, "sensitive": true } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php new file mode 100644 index 00000000..3dacc8e4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -0,0 +1,33 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => 'Alimtalk', + ], + + // 발송 상태 (DispatchStatus enum) + 'status' => [ + 'pending' => 'Pending', + 'sent' => 'Sending', + 'success' => 'Success', + 'failed' => 'Failed', + ], + + // 발송 출처 (DispatchSource enum) + 'source' => [ + 'auto' => 'Automatic', + 'manual' => 'Manual', + 'bulk' => 'Bulk', + ], + + // 결과코드 분류 (ResultCategory enum) + 'result_category' => [ + 'success' => 'Success', + 'retry' => 'Retry', + 'permanent_failure' => 'Permanent Failure', + 'balance_low' => 'Insufficient Balance', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php new file mode 100644 index 00000000..ae9112c2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -0,0 +1,33 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => '알림톡', + ], + + // 발송 상태 (DispatchStatus enum) + 'status' => [ + 'pending' => '대기', + 'sent' => '발송중', + 'success' => '성공', + 'failed' => '실패', + ], + + // 발송 출처 (DispatchSource enum) + 'source' => [ + 'auto' => '자동', + 'manual' => '수동', + 'bulk' => '대량', + ], + + // 결과코드 분류 (ResultCategory enum) + 'result_category' => [ + 'success' => '성공', + 'retry' => '재시도', + 'permanent_failure' => '영구 실패', + 'balance_low' => '잔액 부족', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json new file mode 100644 index 00000000..cd1c67c9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json @@ -0,0 +1,16 @@ +{ + "name": "@plugins/sirsoft-message_bizppurio", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest", + "test:run": "vitest run" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "jsdom": "^25.0.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json new file mode 100644 index 00000000..1a752d1d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -0,0 +1,25 @@ +{ + "identifier": "sirsoft-message_bizppurio", + "vendor": "sirsoft", + "name": { + "ko": "비즈뿌리오 메시징", + "en": "Bizppurio Messaging" + }, + "version": "1.0.0", + "description": { + "ko": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", + "en": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." + }, + "license": "MIT", + "g7_version": ">=7.0.3", + "dependencies": { + "modules": {}, + "plugins": {} + }, + "github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio", + "github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio/blob/main/CHANGELOG.md", + "loading": { + "strategy": "global", + "priority": 100 + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php new file mode 100644 index 00000000..b6b9d2e3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -0,0 +1,367 @@ + 'Sirsoft', + 'license' => 'MIT', + 'homepage' => 'https://sir.kr', + 'keywords' => ['bizppurio', 'sms', 'lms', 'alimtalk', 'kakao', 'messaging', 'notification'], + ]; + } + + /** + * 관리자 메뉴 정의 + * + * 코어 PluginManager 는 모듈과 달리 plugin 의 getAdminMenus() 를 자동 호출하지 않으므로 + * 본 클래스의 activate()/deactivate()/uninstall() lifecycle hook 에서 직접 sync 한다. + * 멱등성: helper.syncMenu 가 upsert 패턴이라 재활성화 시에도 row 중복 없이 그대로 복원. + * + * 최상위 「메시지 발송 관리」 아래에 알림톡 템플릿 관리·발송 이력 하위 메뉴를 둔다. + * (하위 페이지는 Phase 4·5 에서 라우트/레이아웃과 함께 실물화) + * + * @return array> + */ + public function getAdminMenus(): array + { + return [ + [ + 'name' => ['ko' => '메시지 발송 관리', 'en' => 'Messaging'], + 'slug' => 'sirsoft-message_bizppurio', + 'url' => null, + 'icon' => 'fas fa-paper-plane', + 'order' => 55, + 'children' => [ + [ + 'name' => ['ko' => '알림톡 템플릿 관리', 'en' => 'Alimtalk Templates'], + 'slug' => 'sirsoft-message_bizppurio-alimtalk-templates', + 'url' => '/admin/plugins/sirsoft-message_bizppurio/alimtalk-templates', + 'icon' => 'fas fa-comment-dots', + 'order' => 1, + 'permission' => 'sirsoft-message_bizppurio.messaging.view', + ], + [ + 'name' => ['ko' => '발송 이력', 'en' => 'Dispatch History'], + 'slug' => 'sirsoft-message_bizppurio-dispatches', + 'url' => '/admin/plugins/sirsoft-message_bizppurio/dispatches', + 'icon' => 'fas fa-clock-rotate-left', + 'order' => 2, + 'permission' => 'sirsoft-message_bizppurio.messaging.view', + ], + ], + ], + ]; + } + + /** + * 플러그인 활성화 — 관리자 메뉴 자동 등록. + * + * 모듈은 ModuleManager 가 getAdminMenus() 를 자동 처리하지만 플러그인은 + * PluginManager 가 그 책임을 위임하므로 본 메서드에서 helper 를 직접 호출한다. + * syncMenu 가 신규 메뉴 생성 시 admin 역할을 자동 부여하므로 별도 역할 처리는 불필요. + * + * @return bool 활성화 성공 여부 + */ + public function activate(): bool + { + $helper = app(ExtensionMenuSyncHelper::class); + + foreach ($this->getAdminMenus() as $menuData) { + $helper->syncMenuRecursive( + $menuData, + ExtensionOwnerType::Plugin, + $this->getIdentifier(), + ); + } + + return true; + } + + /** + * 플러그인 비활성화 — 관리자 메뉴 일괄 제거. + * + * cleanupStaleMenus 에 currentSlugs=[] 를 넘겨 본 플러그인 소속 메뉴 전체를 삭제한다. + * 자식 메뉴 + role_menus 피벗 정리는 helper 가 cascade 처리. + * + * @return bool 비활성화 성공 여부 + */ + public function deactivate(): bool + { + app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus( + ExtensionOwnerType::Plugin, + $this->getIdentifier(), + currentSlugs: [], + ); + + return true; + } + + /** + * 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리). + * + * @return bool 제거 성공 여부 + */ + public function uninstall(): bool + { + $this->deactivate(); + + return true; + } + + /** + * 플러그인 권한 목록 반환 (계층 구조) + * + * PluginManager 가 1레벨(플러그인 노드) → 2레벨(카테고리) → 3레벨(개별 권한) 트리로 등록. + * 모든 권한은 admin 역할에 매핑. + * + * 권한 분할 의도: + * - view: 발송 이력·알림톡 템플릿 조회 (모니터링) + * - manage: 환경설정·템플릿 등록/검수·이벤트 연동 (운영) + * + * @return array 권한 정의 배열 (categories 계층 구조) + */ + public function getPermissions(): array + { + return [ + 'name' => [ + 'ko' => '비즈뿌리오 메시징', + 'en' => 'Bizppurio Messaging', + ], + 'description' => [ + 'ko' => '비즈뿌리오 메시징 플러그인이 제공하는 권한', + 'en' => 'Permissions provided by the Bizppurio Messaging plugin', + ], + 'categories' => [ + [ + 'identifier' => 'messaging', + 'name' => ['ko' => '메시지 발송', 'en' => 'Messaging'], + 'description' => [ + 'ko' => '메시지 발송 도메인 권한 (조회·관리)', + 'en' => 'Messaging domain permissions (view, manage)', + ], + 'permissions' => [ + [ + 'action' => 'view', + 'name' => ['ko' => '메시지 조회', 'en' => 'View Messaging'], + 'description' => [ + 'ko' => '발송 이력·알림톡 템플릿 조회 (모니터링)', + 'en' => 'View dispatch history and alimtalk templates (monitoring)', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + [ + 'action' => 'manage', + 'name' => ['ko' => '메시지 관리', 'en' => 'Manage Messaging'], + 'description' => [ + 'ko' => '환경설정·알림톡 템플릿 등록/검수·이벤트 연동 관리', + 'en' => 'Manage settings, alimtalk template registration/inspection, and event bindings', + ], + 'type' => 'admin', + 'roles' => ['admin'], + ], + ], + ], + ], + ]; + } + + /** + * 플러그인 설정 스키마 반환 + * + * 관리자 설정 페이지 UI 를 동적으로 생성하는 데 사용됩니다. 크리덴셜(비밀번호·API 키)은 + * sensitive 로 마킹하여 마스킹하며, frontend_schema(defaults.json)에서 expose:false 로 + * 프론트 노출을 차단합니다. + * + * 발송 시스템(account)과 카카오 관리 시스템(bizId)의 식별자는 동일한 '비즈뿌리오 아이디' + * 이므로 bizppurio_id 단일 필드로 받는다. + * + * @return array 설정 스키마 + */ + public function getSettingsSchema(): array + { + return [ + 'environment' => [ + 'type' => 'enum', + 'options' => ['dev', 'live'], + 'default' => 'dev', + 'label' => ['ko' => '연동 환경', 'en' => 'Environment'], + 'hint' => [ + 'ko' => '검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.', + 'en' => 'Select the inspection (dev) or production environment. The sending API domain differs by environment.', + ], + 'required' => false, + ], + 'bizppurio_id' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '비즈뿌리오 아이디', 'en' => 'Bizppurio ID'], + 'hint' => [ + 'ko' => '발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다.', + 'en' => 'The Bizppurio account ID used for both sending and Kakao management.', + ], + 'required' => false, + ], + 'password' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '비밀번호', 'en' => 'Password'], + 'hint' => [ + 'ko' => '발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다.', + 'en' => 'The Bizppurio password used to issue the sending token.', + ], + 'sensitive' => true, + 'required' => false, + ], + 'api_key' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => 'API 키', 'en' => 'API Key'], + 'hint' => [ + 'ko' => '카카오 관리(알림톡 템플릿·발신프로필)에 사용하는 API 키입니다. 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다.', + 'en' => 'The API key used for Kakao management (alimtalk templates, sender profiles).', + ], + 'sensitive' => true, + 'required' => false, + ], + 'sender_number' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '발신번호', 'en' => 'Sender Number'], + 'hint' => [ + 'ko' => '문자·알림톡 발송에 사용하는 발신 전화번호입니다.', + 'en' => 'The sender phone number used for SMS and alimtalk delivery.', + ], + 'required' => false, + ], + 'sender_key' => [ + 'type' => 'string', + 'default' => '', + 'label' => ['ko' => '알림톡 발신프로필 키', 'en' => 'Alimtalk Sender Profile Key'], + 'hint' => [ + 'ko' => '알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다.', + 'en' => 'The 40-character sender profile key used for alimtalk delivery and template lookup.', + ], + 'sensitive' => true, + 'required' => false, + ], + ]; + } + + /** + * 플러그인 설정 기본값 반환 (하위 호환) + * + * 신규 설치 기본값은 config/settings/defaults.json 의 defaults 섹션이 1순위이며, + * 본 메서드는 하위 호환 경로로 동일 값을 반환한다. + * + * @return array 기본 설정값 + */ + public function getConfigValues(): array + { + return [ + 'environment' => 'dev', + 'bizppurio_id' => '', + 'password' => '', + 'api_key' => '', + 'sender_number' => '', + 'sender_key' => '', + ]; + } + + /** + * 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다. + * + * Phase 1 범위: 비즈뿌리오 지갑 잔액부족 시 관리자에게 보내는 자체 알림 1건. + * (webhook 결과코드 9070/7436 감지 시 Phase 4 에서 이 알림을 발화) + * + * ※ 3영역(코어/게시판/이커머스) 알림톡 채널 기본 body 시드는 알림톡 채널 등록(Phase 3)· + * 탭 연동(Phase 6)과 강결합이므로 Phase 6 으로 이관한다. 그때 본 메서드에 정의를 추가한다. + * + * @return array> + */ + public function getNotificationDefinitions(): array + { + return [ + [ + 'type' => 'bizppurio_balance_low', + 'hook_prefix' => 'sirsoft-message_bizppurio', + 'name' => [ + 'ko' => '비즈뿌리오 잔액 부족', + 'en' => 'Bizppurio Balance Low', + ], + 'description' => [ + 'ko' => '비즈뿌리오 지갑 잔액이 부족해 문자/알림톡 발송이 실패했을 때 관리자에게 발송', + 'en' => 'Sent to admin when a message fails due to insufficient Bizppurio wallet balance', + ], + 'channels' => ['mail', 'database'], + 'hooks' => ['sirsoft-message_bizppurio.balance.low'], + 'variables' => [ + ['key' => 'name', 'description' => '수신자(관리자) 이름'], + ['key' => 'app_name', 'description' => '사이트 이름'], + ['key' => 'result_code', 'description' => '결과 코드(9070 문자 / 7436 알림톡)'], + ['key' => 'channel_label', 'description' => '발송 채널(문자/알림톡)'], + ['key' => 'settings_url', 'description' => '메시징 환경설정 URL'], + ['key' => 'site_url', 'description' => '사이트 URL'], + ], + 'templates' => [ + [ + 'channel' => 'mail', + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'subject' => [ + 'ko' => '[{app_name}] 비즈뿌리오 잔액이 부족합니다', + 'en' => '[{app_name}] Bizppurio balance is insufficient', + ], + 'body' => [ + 'ko' => '{name}님, 비즈뿌리오 지갑 잔액이 부족하여 {channel_label} 발송이 실패했습니다 (코드: {result_code}). 충전 후 발송이 정상화됩니다.', + 'en' => 'Dear {name}, a {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}). Delivery resumes after recharging.', + ], + ], + [ + 'channel' => 'database', + 'recipients' => [['type' => 'role', 'value' => 'admin']], + 'subject' => [ + 'ko' => '비즈뿌리오 잔액 부족', + 'en' => 'Bizppurio balance low', + ], + 'body' => [ + 'ko' => '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).', + 'en' => 'A {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}).', + ], + ], + ], + ], + ]; + } + + /** + * 훅 리스너 목록 반환 + * + * Phase 3~6 에서 채널 등록/발송 위임/webhook 리스너를 추가한다. + * + * @return array + */ + public function getHookListeners(): array + { + return []; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts new file mode 100644 index 00000000..74d07367 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts @@ -0,0 +1,120 @@ +/** + * 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼 + * + * JSON 트리에서 ID/이름 검색 및 핸들러·i18n 키 추출을 위한 유틸리티. + */ + +export type AnyNode = Record & { + id?: string; + name?: string; + type?: string; + children?: AnyNode[]; + slots?: Record; + modals?: Record | AnyNode[]; + actions?: AnyNode[]; + iteration?: { source?: string; item_var?: string; index_var?: string }; + if?: string; + props?: Record; + text?: string; +}; + +/** + * 트리 노드에서 특정 id를 재귀 탐색 (children/slots/modals) + */ +export function findById(node: AnyNode | undefined | null, id: string): AnyNode | null { + if (!node) return null; + if (node.id === id) return node; + + if (Array.isArray(node.children)) { + for (const child of node.children) { + const found = findById(child, id); + if (found) return found; + } + } + + if (node.slots && typeof node.slots === 'object') { + for (const slotChildren of Object.values(node.slots)) { + if (Array.isArray(slotChildren)) { + for (const child of slotChildren) { + const found = findById(child, id); + if (found) return found; + } + } + } + } + + if (node.modals) { + const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals); + for (const m of modalEntries) { + const found = findById(m as AnyNode, id); + if (found) return found; + } + } + + return null; +} + +/** + * 트리 노드에서 특정 name 컴포넌트를 모두 수집 (children/slots/modals) + */ +export function findAllByName(node: AnyNode | undefined | null, name: string): AnyNode[] { + const results: AnyNode[] = []; + if (!node) return results; + if (node.name === name) results.push(node); + + if (Array.isArray(node.children)) { + for (const child of node.children) { + results.push(...findAllByName(child, name)); + } + } + if (node.slots && typeof node.slots === 'object') { + for (const slotChildren of Object.values(node.slots)) { + if (Array.isArray(slotChildren)) { + for (const child of slotChildren) { + results.push(...findAllByName(child, name)); + } + } + } + } + if (node.modals) { + const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals); + for (const m of modalEntries) { + results.push(...findAllByName(m as AnyNode, name)); + } + } + + return results; +} + +/** + * 특정 name 컴포넌트 중 props.name 속성이 주어진 값인 첫 노드를 찾음 (폼 입력 필드 탐색용) + */ +export function findInputByName(node: AnyNode | undefined | null, inputName: string): AnyNode | null { + for (const candidate of [...findAllByName(node, 'Input'), ...findAllByName(node, 'Select')]) { + if ((candidate.props as { name?: string } | undefined)?.name === inputName) { + return candidate; + } + } + return null; +} + +/** + * JSON 문자열에서 사용된 핸들러 이름 모두 수집 + */ +export function collectHandlers(json: unknown): string[] { + const text = JSON.stringify(json); + const matches = text.match(/"handler":\s*"([^"]+)"/g) ?? []; + const names = matches + .map((m) => m.match(/"handler":\s*"([^"]+)"/)?.[1] ?? '') + .filter(Boolean); + return Array.from(new Set(names)); +} + +/** + * JSON 문자열에서 사용된 i18n 키($t:sirsoft-message_bizppurio.*) 모두 수집 + */ +export function collectI18nKeys(json: unknown): string[] { + const text = JSON.stringify(json); + const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + return Array.from(new Set(matches)); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx new file mode 100644 index 00000000..5d5e5bea --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -0,0 +1,154 @@ +/** + * 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§6-1) + * + * 3 섹션: + * 1. section_api (연동 환경 + 비즈뿌리오 아이디 + 비밀번호 + API 키) + * 2. section_sending (발신번호 + 알림톡 발신프로필 키) + * 3. section_integration (webhook 수신 주소 안내) + * + * 크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹, 저장은 + * 자동바인딩(_local.form) → 코어 /api/admin/plugins/{id}/settings PUT. + */ + +import { describe, it, expect } from 'vitest'; +import layout from '../../../layouts/admin/plugin_settings.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { + findById, + findInputByName, + collectHandlers, + collectI18nKeys, + type AnyNode, +} from './helpers'; + +const root = layout as unknown as AnyNode; + +describe('plugin_settings.json — 레이아웃 메타/권한', () => { + it('layout_name 이 plugin_settings 이다', () => { + expect((root as { layout_name?: string }).layout_name).toBe('plugin_settings'); + }); + + it('_admin_base 를 상속한다', () => { + expect((root as { extends?: string }).extends).toBe('_admin_base'); + }); + + it('core.plugins.update 권한을 요구한다', () => { + expect((root as { permissions?: string[] }).permissions).toContain('core.plugins.update'); + }); + + it('settings 데이터소스가 코어 플러그인 설정 API 를 조회한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const settings = sources.find((s) => s.id === 'settings'); + expect(settings).toBeTruthy(); + expect(settings?.endpoint).toBe('/api/admin/plugins/{{route.identifier}}/settings'); + expect(settings?.initLocal).toBe('form'); + }); +}); + +describe('plugin_settings.json — 자동바인딩', () => { + it('컨테이너가 dataKey=form + trackChanges 로 자동바인딩한다', () => { + const container = findById(root, 'plugin_settings_content'); + expect(container).toBeTruthy(); + expect((container as { dataKey?: string }).dataKey).toBe('form'); + expect((container as { trackChanges?: boolean }).trackChanges).toBe(true); + }); +}); + +describe('plugin_settings.json — 3 섹션', () => { + it.each([ + ['section_api', 'API 연동'], + ['section_sending', '발송 설정'], + ['section_integration', '연동 안내'], + ])('%s 섹션이 존재한다', (id) => { + expect(findById(root, id)).toBeTruthy(); + }); +}); + +describe('plugin_settings.json — 입력 필드 6종', () => { + it.each([ + 'environment', + 'bizppurio_id', + 'password', + 'api_key', + 'sender_number', + 'sender_key', + ])('%s 입력 필드가 자동바인딩 name 으로 존재한다', (name) => { + expect(findInputByName(root, name)).toBeTruthy(); + }); + + it('크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹된다', () => { + for (const cred of ['password', 'api_key', 'sender_key']) { + const input = findInputByName(root, cred); + expect((input?.props as { type?: string } | undefined)?.type).toBe('password'); + } + }); + + it('bizppurio_id/sender_number 는 일반 text 입력이다', () => { + for (const field of ['bizppurio_id', 'sender_number']) { + const input = findInputByName(root, field); + expect((input?.props as { type?: string } | undefined)?.type).toBe('text'); + } + }); + + it('environment 는 dev/live 옵션 Select 이다', () => { + const select = findInputByName(root, 'environment'); + const options = (select?.children ?? []).map( + (c) => (c.props as { value?: string } | undefined)?.value + ); + expect(options).toEqual(['dev', 'live']); + }); +}); + +describe('plugin_settings.json — webhook 안내', () => { + it('연동 안내 섹션에 고정 webhook 경로가 readonly 로 표시된다', () => { + const section = findById(root, 'section_integration'); + const inputs = JSON.stringify(section); + expect(inputs).toContain('/api/plugins/sirsoft-message_bizppurio/webhook'); + expect(inputs).toContain('"readOnly":true'); + }); +}); + +describe('plugin_settings.json — 저장 버튼', () => { + it('저장 버튼이 hasChanges 없으면 비활성화된다', () => { + const save = findById(root, 'save_button'); + expect((save?.props as { disabled?: string } | undefined)?.disabled).toContain('!_local.hasChanges'); + }); + + it('저장은 코어 설정 API 로 PUT 한다', () => { + const text = JSON.stringify(findById(root, 'save_button')); + expect(text).toContain('/api/admin/plugins/{{route.identifier}}/settings'); + expect(text).toContain('"method":"PUT"'); + expect(text).toContain('{{_local.form}}'); + }); + + it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { + const handlers = collectHandlers(layout); + const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView']; + for (const h of handlers) { + expect(allowed).toContain(h); + } + }); +}); + +describe('plugin_settings.json — i18n 키 정합', () => { + it('레이아웃이 참조하는 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => { + const keys = collectI18nKeys(layout); + expect(keys.length).toBeGreaterThan(0); + + const resolve = (dict: Record, path: string): unknown => + path.split('.').reduce((acc, seg) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[seg]; + } + return undefined; + }, dict); + + for (const raw of keys) { + // "$t:sirsoft-message_bizppurio.settings.title" → "settings.title" + const path = raw.replace('$t:sirsoft-message_bizppurio.', ''); + expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy(); + expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy(); + } + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json new file mode 100644 index 00000000..ef90355f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -0,0 +1,60 @@ +{ + "name": "Bizppurio Messaging", + "description": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk messaging plugin.", + "settings": { + "title": "Bizppurio Messaging Settings", + "description": "Manage Bizppurio integration credentials and sending options.", + "save": "Save", + "saving": "Saving...", + "save_success": "Settings saved.", + "save_failed": "Failed to save settings.", + "sections": { + "api": { + "title": "API Integration", + "description": "Credentials shared by the sending system and the Kakao management system." + }, + "sending": { + "title": "Sending Options", + "description": "Sender information used for SMS and alimtalk delivery." + }, + "integration": { + "title": "Integration Guide", + "description": "The webhook address that receives delivery results." + } + }, + "fields": { + "environment": { + "label": "Environment", + "hint": "Select the inspection (dev) or production environment. The sending API domain differs by environment.", + "option_dev": "Inspection (Dev)", + "option_live": "Production" + }, + "bizppurio_id": { + "label": "Bizppurio ID", + "hint": "The Bizppurio account ID used for both sending and Kakao management." + }, + "password": { + "label": "Password", + "hint": "The Bizppurio password used to issue the sending token." + }, + "api_key": { + "label": "API Key", + "hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support." + }, + "sender_number": { + "label": "Sender Number", + "hint": "The sender phone number used for SMS and alimtalk delivery." + }, + "sender_key": { + "label": "Alimtalk Sender Profile Key", + "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." + } + }, + "webhook": { + "label": "Delivery Result Address (webhook)", + "hint": "Register this address with the Bizppurio business team as the URL PUSH endpoint so delivery results are recorded automatically.", + "copy": "Copy", + "copied": "Copied." + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json new file mode 100644 index 00000000..e1d93a1f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -0,0 +1,60 @@ +{ + "name": "비즈뿌리오 메시징", + "description": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다.", + "settings": { + "title": "비즈뿌리오 메시징 설정", + "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.", + "save": "저장", + "saving": "저장 중...", + "save_success": "설정이 저장되었습니다.", + "save_failed": "설정 저장에 실패했습니다.", + "sections": { + "api": { + "title": "API 연동", + "description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다." + }, + "sending": { + "title": "발송 설정", + "description": "문자·알림톡 발송에 사용하는 발신 정보입니다." + }, + "integration": { + "title": "연동 안내", + "description": "발송 결과를 수신하는 webhook 주소입니다." + } + }, + "fields": { + "environment": { + "label": "연동 환경", + "hint": "검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.", + "option_dev": "검수(개발)", + "option_live": "운영" + }, + "bizppurio_id": { + "label": "비즈뿌리오 아이디", + "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." + }, + "password": { + "label": "비밀번호", + "hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다." + }, + "api_key": { + "label": "API 키", + "hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다." + }, + "sender_number": { + "label": "발신번호", + "hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다." + }, + "sender_key": { + "label": "알림톡 발신프로필 키", + "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." + } + }, + "webhook": { + "label": "발송 결과 수신 주소 (webhook)", + "hint": "비즈뿌리오 사업팀에 이 주소를 URL PUSH 수신 주소로 등록하면 발송 결과가 이력에 자동 기록됩니다.", + "copy": "복사", + "copied": "복사되었습니다." + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json new file mode 100644 index 00000000..751cdb1e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -0,0 +1,644 @@ +{ + "version": "1.0.0", + "layout_name": "plugin_settings", + "permissions": [ + "core.plugins.update" + ], + "extends": "_admin_base", + "meta": { + "title": "$t:sirsoft-message_bizppurio.settings.title", + "description": "$t:sirsoft-message_bizppurio.settings.description" + }, + "data_sources": [ + { + "id": "settings", + "type": "api", + "endpoint": "/api/admin/plugins/{{route.identifier}}/settings", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "initLocal": "form", + "refetchOnMount": true + } + ], + "slots": { + "content": [ + { + "id": "plugin_settings_content", + "type": "basic", + "name": "Div", + "dataKey": "form", + "trackChanges": true, + "props": { + "className": "p-4 sm:p-6 lg:p-8 min-h-screen bg-gray-50 dark:bg-gray-900" + }, + "children": [ + { + "id": "page_header", + "type": "composite", + "name": "PageHeader", + "props": { + "title": "$t:sirsoft-message_bizppurio.settings.title", + "description": "$t:sirsoft-message_bizppurio.settings.description", + "backPath": "/admin/plugins", + "backLabel": "$t:admin.plugins.back_to_list" + } + }, + { + "id": "validation_error", + "type": "basic", + "name": "Div", + "if": "{{_local.errors}}", + "props": { + "id": "bizppurio_settings_validation_error", + "role": "alert", + "aria-live": "assertive", + "tabIndex": -1, + "className": "alert-danger scroll-mt-32" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex gap-3" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "triangle-exclamation", + "size": "md", + "className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-error-soft" + }, + "text": "$t:admin.plugins.settings.validation_error" + }, + { + "type": "basic", + "name": "Ul", + "if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}", + "props": { + "className": "mt-2 list-disc list-inside space-y-1" + }, + "iteration": { + "source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])", + "item_var": "message" + }, + "children": [ + { + "type": "basic", + "name": "Li", + "text": "{{message}}" + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "section_api", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-base font-semibold text-gray-900 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.api.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.api.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_environment", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.label" + }, + { + "type": "basic", + "name": "Select", + "props": { + "name": "environment", + "className": "input mt-1" + }, + "children": [ + { + "type": "basic", + "name": "Option", + "props": { + "value": "dev" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_dev" + }, + { + "type": "basic", + "name": "Option", + "props": { + "value": "live" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_live" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.hint" + } + ] + }, + { + "id": "field_bizppurio_id", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "bizppurio_id", + "autoComplete": "off", + "className": "input mt-1" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" + } + ] + }, + { + "id": "field_password", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "password", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "input mt-1 font-mono" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" + } + ] + }, + { + "id": "field_api_key", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "api_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "input mt-1 font-mono" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" + } + ] + } + ] + } + ] + }, + { + "id": "section_sending", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-base font-semibold text-gray-900 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-6" + }, + "children": [ + { + "id": "field_sender_number", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "sender_number", + "autoComplete": "off", + "className": "input mt-1" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" + } + ] + }, + { + "id": "field_sender_key", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "sender_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "input mt-1 font-mono" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" + } + ] + } + ] + } + ] + }, + { + "id": "section_integration", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "text-base font-semibold text-gray-900 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "p-6 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.settings.webhook.label" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "readOnly": true, + "value": "/api/plugins/sirsoft-message_bizppurio/webhook", + "className": "input flex-1 font-mono text-sm bg-gray-50 dark:bg-gray-900" + } + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:sirsoft-message_bizppurio.settings.webhook.hint" + } + ] + } + ] + }, + { + "id": "footer_buttons", + "type": "basic", + "name": "Div", + "props": { + "className": "sticky bottom-0 z-10 flex justify-end items-center gap-3 mt-6 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700" + }, + "children": [ + { + "id": "cancel_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "btn btn-secondary" + }, + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins" + } + } + ], + "children": [ + { + "type": "basic", + "name": "Span", + "text": "$t:common.cancel" + } + ] + }, + { + "id": "save_button", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "disabled": "{{!_local.hasChanges || _local.isSaving}}", + "className": "btn btn-primary flex-center gap-1.5" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": true, + "errors": null + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/admin/plugins/{{route.identifier}}/settings", + "params": { + "method": "PUT", + "body": "{{_local.form}}" + }, + "onSuccess": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "hasChanges": false + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:admin.plugins.settings.save_success" + } + }, + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "settings" + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "local", + "isSaving": false, + "errors": "{{error.errors ?? { _general: error.message }}}" + } + }, + { + "handler": "scrollIntoView", + "params": { + "selector": "#bizppurio_settings_validation_error", + "behavior": "smooth", + "block": "start", + "waitForElement": true, + "timeout": 2000 + } + }, + { + "handler": "toast", + "params": { + "type": "error", + "message": "$t:admin.plugins.settings.save_error" + } + } + ] + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "if": "{{_local.isSaving}}", + "props": { + "name": "spinner", + "className": "spinner-sm" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{!_local.isSaving}}", + "text": "$t:common.save" + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.isSaving}}", + "text": "$t:common.saving" + } + ] + } + ] + } + ] + } + ] + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php new file mode 100644 index 00000000..58ede0a3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php @@ -0,0 +1,52 @@ + __('sirsoft-message_bizppurio::messages.channel.sms'), + self::Lms => __('sirsoft-message_bizppurio::messages.channel.lms'), + self::Alimtalk => __('sirsoft-message_bizppurio::messages.channel.alimtalk'), + }; + } + + /** + * 문자(SMS/LMS) 채널 여부를 반환합니다. + * + * @return bool 문자 채널이면 true, 알림톡이면 false + */ + public function isText(): bool + { + return $this === self::Sms || $this === self::Lms; + } + + /** + * 모든 채널 값 목록을 반환합니다. + * + * @return array 채널 문자열 배열 (예: ["sms", "lms", "alimtalk"]) + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php new file mode 100644 index 00000000..a81cd214 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php @@ -0,0 +1,42 @@ + __('sirsoft-message_bizppurio::messages.source.auto'), + self::Manual => __('sirsoft-message_bizppurio::messages.source.manual'), + self::Bulk => __('sirsoft-message_bizppurio::messages.source.bulk'), + }; + } + + /** + * 모든 출처 값 목록을 반환합니다. + * + * @return array 출처 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php new file mode 100644 index 00000000..0377e545 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php @@ -0,0 +1,54 @@ + __('sirsoft-message_bizppurio::messages.status.pending'), + self::Sent => __('sirsoft-message_bizppurio::messages.status.sent'), + self::Success => __('sirsoft-message_bizppurio::messages.status.success'), + self::Failed => __('sirsoft-message_bizppurio::messages.status.failed'), + }; + } + + /** + * 리포트 수신으로 상태가 확정되었는지(성공/실패) 여부를 반환합니다. + * + * @return bool 성공 또는 실패이면 true, 대기/발송중이면 false + */ + public function isFinal(): bool + { + return $this === self::Success || $this === self::Failed; + } + + /** + * 모든 상태 값 목록을 반환합니다. + * + * @return array 상태 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php new file mode 100644 index 00000000..4475233d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php @@ -0,0 +1,69 @@ + __('sirsoft-message_bizppurio::messages.result_category.success'), + self::Retry => __('sirsoft-message_bizppurio::messages.result_category.retry'), + self::PermanentFailure => __('sirsoft-message_bizppurio::messages.result_category.permanent_failure'), + self::BalanceLow => __('sirsoft-message_bizppurio::messages.result_category.balance_low'), + }; + } + + /** + * 재시도 대상 분류 여부를 반환합니다. + * + * @return bool 일시 오류(retry)이면 true + */ + public function isRetryable(): bool + { + return $this === self::Retry; + } + + /** + * 발송 실패로 확정되는 분류 여부를 반환합니다. + * + * @return bool 영구 실패 또는 잔액 부족이면 true + */ + public function isFailure(): bool + { + return $this === self::PermanentFailure || $this === self::BalanceLow; + } + + /** + * 모든 분류 값 목록을 반환합니다. + * + * @return array 분류 문자열 배열 + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php new file mode 100644 index 00000000..6e19a775 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -0,0 +1,58 @@ + + */ + protected array $repositories = [ + // Phase 4: BizppurioDispatchRepositoryInterface / BizppurioNotificationBindingRepositoryInterface + ]; + + /** + * CacheInterface 가 필요한 서비스 (contextual binding). + * + * Phase 2 에서 발송 토큰 캐시 서비스(BizppurioTokenService)를 등록한다. + * + * @var array + */ + protected array $cacheServices = [ + // Phase 2: BizppurioTokenService::class + ]; + + /** + * 플러그인 루트 lang 디렉토리를 로드합니다. + * + * 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()` + * (= 플러그인 식별자) 네임스페이스로 로드한다. + * + * @return void + */ + protected function loadExtensionTranslations(): void + { + $langPath = dirname($this->getProviderPath(), 2).'/lang'; + + if (is_dir($langPath)) { + $this->loadTranslationsFrom($langPath, $this->translationNamespace()); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php new file mode 100644 index 00000000..448f2cd5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -0,0 +1,140 @@ +plugin = new Plugin; + } + + public function test_식별자가_디렉토리명과_일치한다(): void + { + $this->assertSame('sirsoft-message_bizppurio', $this->plugin->getIdentifier()); + $this->assertSame('sirsoft', $this->plugin->getVendor()); + } + + public function test_권한이_view_manage_2개로_선언된다(): void + { + $permissions = $this->plugin->getPermissions(); + + $this->assertArrayHasKey('categories', $permissions); + $this->assertCount(1, $permissions['categories']); + + $category = $permissions['categories'][0]; + $this->assertSame('messaging', $category['identifier']); + + $actions = array_column($category['permissions'], 'action'); + $this->assertEqualsCanonicalizing(['view', 'manage'], $actions); + + foreach ($category['permissions'] as $permission) { + $this->assertContains('admin', $permission['roles']); + $this->assertSame('admin', $permission['type']); + } + } + + public function test_설정_스키마의_크리덴셜은_sensitive다(): void + { + $schema = $this->plugin->getSettingsSchema(); + + foreach (['password', 'api_key', 'sender_key'] as $credential) { + $this->assertArrayHasKey($credential, $schema); + $this->assertTrue( + $schema[$credential]['sensitive'] ?? false, + "{$credential} 는 sensitive 로 마킹되어야 한다." + ); + } + + // 비크리덴셜 필드는 존재하되 sensitive 아님 + $this->assertArrayHasKey('bizppurio_id', $schema); + $this->assertArrayHasKey('sender_number', $schema); + $this->assertArrayHasKey('environment', $schema); + $this->assertSame('dev', $schema['environment']['default']); + } + + public function test_defaults_json의_크리덴셜은_expose_false다(): void + { + $path = dirname(__DIR__, 3).'/config/settings/defaults.json'; + $this->assertFileExists($path); + + $defaults = json_decode(file_get_contents($path), true); + $schema = $defaults['frontend_schema']; + + foreach ($schema as $field => $rule) { + $this->assertFalse( + $rule['expose'] ?? true, + "{$field} 는 프론트에 노출(expose:true)되면 안 된다." + ); + } + + foreach (['password', 'api_key', 'sender_key'] as $credential) { + $this->assertTrue($schema[$credential]['sensitive'] ?? false); + } + } + + public function test_잔액부족_알림이_관리자_대상으로_정의된다(): void + { + $definitions = $this->plugin->getNotificationDefinitions(); + + $this->assertCount(1, $definitions); + $definition = $definitions[0]; + + $this->assertSame('bizppurio_balance_low', $definition['type']); + $this->assertSame('sirsoft-message_bizppurio', $definition['hook_prefix']); + $this->assertEqualsCanonicalizing(['mail', 'database'], $definition['channels']); + + foreach ($definition['templates'] as $template) { + $recipients = $template['recipients']; + $this->assertSame('role', $recipients[0]['type']); + $this->assertSame('admin', $recipients[0]['value']); + } + } + + public function test_activate_시_관리자_메뉴가_계층으로_생성된다(): void + { + $this->plugin->activate(); + + $parent = Menu::where('slug', 'sirsoft-message_bizppurio') + ->where('extension_type', ExtensionOwnerType::Plugin->value) + ->first(); + + $this->assertNotNull($parent, '최상위 메뉴가 생성되어야 한다.'); + + $children = Menu::where('parent_id', $parent->id)->get(); + $this->assertCount(2, $children, '알림톡 템플릿 관리·발송 이력 2개 하위 메뉴가 생성되어야 한다.'); + + $childSlugs = $children->pluck('slug')->all(); + $this->assertContains('sirsoft-message_bizppurio-alimtalk-templates', $childSlugs); + $this->assertContains('sirsoft-message_bizppurio-dispatches', $childSlugs); + } + + public function test_deactivate_시_메뉴가_제거된다(): void + { + $this->plugin->activate(); + $this->assertGreaterThan(0, Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count()); + + $this->plugin->deactivate(); + + $this->assertSame( + 0, + Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count(), + '비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.' + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php new file mode 100644 index 00000000..979844b5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -0,0 +1,107 @@ +snapshotHookManager(); + } + + /** + * tearDown 에 HookManager 상태 복원. + * + * @return void + */ + protected function tearDown(): void + { + $this->restoreHookManager(); + + parent::tearDown(); + } + + /** + * HookManager static $hooks / $filters / $dispatching 를 스냅샷. + * + * @return void + */ + private function snapshotHookManager(): void + { + $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $this->hookSnapshot = [ + 'hooks' => $ref->getProperty('hooks')->getValue(), + 'filters' => $ref->getProperty('filters')->getValue(), + 'dispatching' => $ref->getProperty('dispatching')->getValue(), + ]; + } + + /** + * 스냅샷 시점으로 HookManager 복원. + * + * @return void + */ + private function restoreHookManager(): void + { + if ($this->hookSnapshot === null) { + return; + } + + $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); + $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); + $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); + + $this->hookSnapshot = null; + } + + /** + * 마이그레이션 경로를 반환합니다. + * + * RefreshDatabase 의 migrate:fresh 명령에 코어 + 번들 확장 마이그레이션 경로를 전달합니다. + * + * @return array + */ + protected function migrateFreshUsing(): array + { + $paths = ['database/migrations']; + foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) { + $paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p); + } + + return [ + '--drop-views' => $this->shouldDropViews(), + '--drop-types' => $this->shouldDropTypes(), + '--seed' => false, + '--path' => $paths, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php new file mode 100644 index 00000000..6d3718c9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php @@ -0,0 +1,64 @@ +assertEqualsCanonicalizing(['sms', 'lms', 'alimtalk'], DispatchChannel::values()); + $this->assertTrue(DispatchChannel::Sms->isText()); + $this->assertTrue(DispatchChannel::Lms->isText()); + $this->assertFalse(DispatchChannel::Alimtalk->isText()); + $this->assertNull(DispatchChannel::tryFrom('invalid')); + } + + public function test_dispatch_status_값과_final_판정(): void + { + $this->assertEqualsCanonicalizing(['pending', 'sent', 'success', 'failed'], DispatchStatus::values()); + $this->assertFalse(DispatchStatus::Pending->isFinal()); + $this->assertFalse(DispatchStatus::Sent->isFinal()); + $this->assertTrue(DispatchStatus::Success->isFinal()); + $this->assertTrue(DispatchStatus::Failed->isFinal()); + } + + public function test_dispatch_source_값(): void + { + $this->assertEqualsCanonicalizing(['auto', 'manual', 'bulk'], DispatchSource::values()); + $this->assertSame('auto', DispatchSource::Auto->value); + } + + public function test_result_category_분류_판정(): void + { + $this->assertEqualsCanonicalizing( + ['success', 'retry', 'permanent_failure', 'balance_low'], + ResultCategory::values() + ); + + $this->assertTrue(ResultCategory::Retry->isRetryable()); + $this->assertFalse(ResultCategory::Success->isRetryable()); + + $this->assertTrue(ResultCategory::PermanentFailure->isFailure()); + $this->assertTrue(ResultCategory::BalanceLow->isFailure()); + $this->assertFalse(ResultCategory::Success->isFailure()); + $this->assertFalse(ResultCategory::Retry->isFailure()); + } + + public function test_label은_다국어_문자열을_반환한다(): void + { + // label() 이 예외 없이 문자열을 반환하는지 (lang 키 해석 스모크) + $this->assertIsString(DispatchChannel::Sms->label()); + $this->assertIsString(DispatchStatus::Success->label()); + $this->assertIsString(DispatchSource::Auto->label()); + $this->assertIsString(ResultCategory::BalanceLow->label()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml new file mode 100644 index 00000000..e76e0f21 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -0,0 +1,66 @@ +# audit:allow test-scenario-coverage reason: | +# Phase 1 환경설정 화면은 입력·저장이 코어 /api/admin/plugins/{id}/settings 에 위임되는 +# 단순 자동바인딩 폼이라, axes cross product 의 런타임 효과(저장 성공/실패 왕복)는 코어 설정 +# 컨트롤러가 책임진다. 본 확장의 effects(3섹션 구조 / 6필드 / 크리덴셜 마스킹 / webhook 안내 / +# 저장 버튼 게이팅 / i18n 정합 / 권한·메뉴·알림 정의 / 설정 초기값 로드)는 PHPUnit 12건 +# (InstallationTest·EnumTest) + Vitest 22건(plugin_settings.test.tsx) 정적 검증으로 가드된다. +# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 인증·발송 환경 필요) +# 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. 그때까지 +# 매니페스트는 명세로 유지한다. + +feature: 비즈뿌리오 메시징 환경설정 (Phase 1) + +description: | + 관리자 환경설정 화면(admin/plugin_settings.json)에서 비즈뿌리오 연동 정보를 입력·저장한다. + 코어 플러그인 설정 인프라(/api/admin/plugins/{id}/settings)에 자동바인딩(_local.form)으로 + 위임하며, 3 섹션(API 연동 / 발송 설정 / 연동 안내)으로 구성된다. + + 핵심 동작: + - API 연동: 연동 환경(dev/live) + 비즈뿌리오 아이디 + 비밀번호 + API 키 + - 발송 설정: 발신번호 + 알림톡 발신프로필 키 + - 연동 안내: 발송 결과 수신 webhook 주소(readonly 표시) + - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false + - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT + - 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드 + + ※ 알림톡 채널 기본 body 시드(3영역)는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이므로 + Phase 6 으로 이관(plugin.php getNotificationDefinitions 주석 참조). + +axes: + credential_state: [empty, filled] + environment: [dev, live] + field_kind: [credential, plain, readonly] + +exclusions: + - { field_kind: readonly, credential_state: filled, reason: "webhook 주소는 고정 표시값 — 운영자 입력 대상 아님" } + +effects: + # 섹션 구조 + - three_sections_api_sending_integration_present + # 입력 필드 + - six_input_fields_bound_by_name + - credential_fields_masked_as_password + - plain_fields_use_text_input + - environment_select_has_dev_live_options + # 연동 안내 + - webhook_address_shown_readonly + # 저장 + - save_button_disabled_without_changes + - save_puts_to_core_plugin_settings_api + - only_registered_handlers_used + # 다국어 + - all_referenced_i18n_keys_exist_in_ko_and_en + # 설치·활성화 (백엔드) + - install_creates_view_and_manage_permissions + - activate_creates_hierarchical_admin_menus + - deactivate_removes_plugin_menus + - balance_low_notification_targets_admin + - settings_defaults_load_with_dev_environment + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json new file mode 100644 index 00000000..170e1528 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "moduleResolution": "bundler", + "resolveJsonModule": true, + "esModuleInterop": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "@/*": ["resources/js/*"] + } + }, + "include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"] +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts new file mode 100644 index 00000000..ffd85611 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['resources/js/__tests__/**/*.test.{ts,tsx}'], + setupFiles: [], + }, + resolve: { + alias: { + '@': resolve(__dirname, 'resources/js'), + }, + }, +}); From 6549170bda1af50d12025b349e931eb3c8b99ac3 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 10 Jul 2026 10:17:53 +0900 Subject: [PATCH 02/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EB=B9=84?= =?UTF-8?q?=EC=A6=88=EB=BF=8C=EB=A6=AC=EC=98=A4=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=97=94=EC=A7=84=20API=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발송 시스템(api.bizppurio.com)과 카카오 관리 시스템(kapi.ppurio.com)의 API 클라이언트를 인증 방식(토큰 vs bizId+apiKey)에 따라 분리 구현했다. 토큰 캐시(23h)·재발급, SMS/LMS EUC-KR byte 자동 판별, ShouldQueue 발송 Job(429·일시오류 재시도, 영구실패 즉시)을 포함한다. 실제 발송 없이 Http::fake 단위테스트 30건으로 검증했다. --- .../lang/en/messages.php | 11 ++ .../lang/ko/messages.php | 11 ++ .../src/Exceptions/BizppurioApiException.php | 60 ++++++ .../src/Jobs/SendMessageJob.php | 114 +++++++++++ .../MessageBizppurioServiceProvider.php | 5 +- .../src/Services/BizppurioApiClient.php | 141 +++++++++++++ .../src/Services/BizppurioKakaoApiClient.php | 187 ++++++++++++++++++ .../src/Services/BizppurioTokenService.php | 160 +++++++++++++++ .../src/Services/MessagePayloadBuilder.php | 146 ++++++++++++++ .../src/Services/SmsTypeResolver.php | 90 +++++++++ .../tests/PluginTestCase.php | 13 +- .../tests/Unit/Jobs/SendMessageJobTest.php | 88 +++++++++ .../Unit/Services/BizppurioApiClientTest.php | 133 +++++++++++++ .../Services/BizppurioKakaoApiClientTest.php | 115 +++++++++++ .../Services/BizppurioTokenServiceTest.php | 149 ++++++++++++++ .../Services/MessagePayloadBuilderTest.php | 101 ++++++++++ .../Unit/Services/SmsTypeResolverTest.php | 63 ++++++ 17 files changed, 1574 insertions(+), 13 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 3dacc8e4..ea430054 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -30,4 +30,15 @@ return [ 'permanent_failure' => 'Permanent Failure', 'balance_low' => 'Insufficient Balance', ], + + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) + 'error' => [ + 'credentials_missing' => 'Please set the Bizppurio ID and password first.', + 'token_issue_failed' => 'Failed to issue the Bizppurio authentication token.', + 'send_failed' => 'Failed to send the message.', + 'send_retryable' => 'Message delivery temporarily failed. (code: :code)', + 'invalid_response' => 'Unable to parse the Bizppurio response.', + 'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.', + 'kakao_request_failed' => 'The Kakao management API request failed.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index ae9112c2..bf8e501f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -30,4 +30,15 @@ return [ 'permanent_failure' => '영구 실패', 'balance_low' => '잔액 부족', ], + + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) + 'error' => [ + 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.', + 'token_issue_failed' => '비즈뿌리오 인증 토큰 발급에 실패했습니다.', + 'send_failed' => '메시지 발송 요청에 실패했습니다.', + 'send_retryable' => '메시지 발송이 일시적으로 실패했습니다. (코드: :code)', + 'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.', + 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.', + 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php new file mode 100644 index 00000000..cceb808b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php @@ -0,0 +1,60 @@ +resultCode; + } + + /** + * HTTP 상태 코드를 반환합니다. + * + * @return int|null HTTP 상태 코드, 없으면 null + */ + public function getHttpStatus(): ?int + { + return $this->httpStatus; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php new file mode 100644 index 00000000..244e9c38 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -0,0 +1,114 @@ + $payload 발송 payload (MessagePayloadBuilder 조립) + * @param string $refkey 우리 부여 키 (이력 매칭용) + */ + public function __construct( + public readonly array $payload, + public readonly string $refkey, + ) { + $this->afterCommit = true; + } + + /** + * 발송을 수행하고 결과코드에 따라 재시도/실패를 판정합니다. + * + * @param BizppurioApiClient $client 발송 API 클라이언트 + * + * @throws BizppurioApiException 일시 오류·429 시(큐 재시도 트리거) + */ + public function handle(BizppurioApiClient $client): void + { + $result = $client->sendMessage($this->payload); + $code = (string) ($result['code'] ?? ''); + + if ($client->isSuccess($result)) { + // 발송 접수 성공. 이력 갱신(messagekey 저장)은 Phase 4 훅이 담당. + return; + } + + if (in_array($code, self::RETRYABLE_CODES, true)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.send_retryable', ['code' => $code]), + resultCode: $code, + ); + } + + // 영구 실패: 재시도하지 않고 종료. 실패 이력 기록은 Phase 4 가 담당. + Log::warning('비즈뿌리오 발송 영구 실패', [ + 'refkey' => $this->refkey, + 'code' => $code, + 'description' => $result['description'] ?? null, + ]); + } + + /** + * 재시도 사이 대기 시간(초)을 반환합니다. + * + * 429·일시오류 재시도 시 짧게 backoff 한다. sync 실행 시에도 이 값이 적용된다. + * + * @return int 대기 초 + */ + public function backoff(): int + { + return self::SYNC_RETRY_MAX_WAIT_SECONDS; + } + + /** + * 모든 재시도 소진 후 최종 실패 시 로그를 남깁니다. + * + * @param Throwable $exception 마지막으로 발생한 예외 + */ + public function failed(Throwable $exception): void + { + Log::error('비즈뿌리오 발송 Job 최종 실패', [ + 'refkey' => $this->refkey, + 'error' => $exception->getMessage(), + 'result_code' => $exception instanceof BizppurioApiException + ? $exception->getResultCode() + : null, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 6e19a775..2ef09e01 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; +use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; /** * 비즈뿌리오 메시징 플러그인 서비스 프로바이더. @@ -36,7 +37,7 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * @var array */ protected array $cacheServices = [ - // Phase 2: BizppurioTokenService::class + BizppurioTokenService::class, ]; /** @@ -44,8 +45,6 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * * 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()` * (= 플러그인 식별자) 네임스페이스로 로드한다. - * - * @return void */ protected function loadExtensionTranslations(): void { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php new file mode 100644 index 00000000..4b915b1a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php @@ -0,0 +1,141 @@ + $payload MessagePayloadBuilder 가 조립한 발송 본문 + * @return array 응답 배열 (code/description/messagekey/refkey) + * + * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시 + */ + public function sendMessage(array $payload): array + { + $result = $this->postMessage($payload, $this->tokenService->getToken()); + + // 토큰·인증 무효 → 재발급 후 1회 재시도 + if (in_array((string) ($result['code'] ?? ''), self::AUTH_INVALID_CODES, true)) { + $result = $this->postMessage($payload, $this->tokenService->refreshToken()); + } + + return $result; + } + + /** + * 발송 응답 결과코드가 성공(1000)인지 판정합니다. + * + * @param array $result sendMessage 응답 + * @return bool 성공이면 true + */ + public function isSuccess(array $result): bool + { + return (string) ($result['code'] ?? '') === self::SUCCESS_CODE; + } + + /** + * 단일 발송 요청을 수행합니다. + * + * @param array $payload 발송 본문 + * @param string $token Bearer 액세스 토큰 + * @return array 응답 배열 + * + * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시 + */ + private function postMessage(array $payload, string $token): array + { + $response = $this->http() + ->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json; charset=utf-8', + ]) + ->post($this->baseUrl().'/v3/message', $payload); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.send_failed'), + httpStatus: $response->status(), + ); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + + /** + * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다. + * + * @return string 베이스 URL (스킴 포함) + */ + private function baseUrl(): string + { + $environment = (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'environment', 'dev'); + + return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php new file mode 100644 index 00000000..1404fcff --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -0,0 +1,187 @@ + 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getSenderProfiles(): array + { + return $this->post('/v3/kakao/profile/use'); + } + + /** + * 알림톡 템플릿 목록을 조회합니다. (`/v3/kakao/template/list`) + * + * @param string $senderKey 발신프로필 키 + * @param array $params 추가 조회 파라미터 (count/page/status 등) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getTemplateList(string $senderKey, array $params = []): array + { + return $this->post('/v3/kakao/template/list', array_merge($params, [ + 'senderKey' => $senderKey, + ])); + } + + /** + * 알림톡 템플릿 상세를 조회합니다. (`/v3/kakao/template/detail`) + * + * @param string $senderKey 발신프로필 키 + * @param string $templateCode 템플릿 코드 + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function getTemplateDetail(string $senderKey, string $templateCode): array + { + return $this->post('/v3/kakao/template/detail', [ + 'senderKey' => $senderKey, + 'templateCode' => $templateCode, + ]); + } + + /** + * 카카오 관리 API 의 임의 엔드포인트를 호출합니다. + * + * Phase 5·6 의 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는 + * 자동으로 주입되므로 도메인 파라미터만 전달한다. + * + * @param string $path 엔드포인트 경로 (예: '/v3/kakao/template/add') + * @param array $params 요청 파라미터 (bizId/apiKey 제외) + * @return array 응답 배열 (code/message/data) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function request(string $path, array $params = []): array + { + return $this->post($path, $params); + } + + /** + * 카카오 관리 API 응답이 성공(200)인지 판정합니다. + * + * @param array $result 응답 배열 + * @return bool 성공이면 true + */ + public function isSuccess(array $result): bool + { + return (string) ($result['code'] ?? '') === self::SUCCESS_CODE; + } + + /** + * bizId/apiKey 를 주입해 카카오 관리 API 를 POST 호출합니다. + * + * @param string $path 엔드포인트 경로 + * @param array $params 요청 파라미터 (bizId/apiKey 제외) + * @return array 응답 배열 + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + private function post(string $path, array $params = []): array + { + [$bizId, $apiKey] = $this->credentials(); + + $response = $this->http() + ->withHeaders(['Content-Type' => 'application/json; charset=utf-8']) + ->post(self::BASE_URL.$path, array_merge($params, [ + 'bizId' => $bizId, + 'apiKey' => $apiKey, + ])); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + httpStatus: $response->status(), + ); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + + /** + * 환경설정에서 bizId(=bizppurio_id)와 apiKey 를 조회합니다. + * + * @return array{0: string, 1: string} [bizId, apiKey] + * + * @throws BizppurioApiException 자격증명 미설정 시 + */ + private function credentials(): array + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $bizId = (string) ($settings['bizppurio_id'] ?? ''); + $apiKey = (string) ($settings['api_key'] ?? ''); + + if ($bizId === '' || $apiKey === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.kakao_credentials_missing'), + ); + } + + return [$bizId, $apiKey]; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php new file mode 100644 index 00000000..12f73b30 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php @@ -0,0 +1,160 @@ +cache->remember( + self::CACHE_KEY, + fn (): string => $this->issueToken(), + self::CACHE_TTL_SECONDS, + ); + } + + /** + * 캐시된 토큰을 무효화하고 새 토큰을 발급받아 반환합니다. + * + * 발송 응답이 3002/3005(토큰·인증 무효)일 때 호출한다. + * + * @return string 새로 발급받은 Bearer 액세스 토큰 + * + * @throws BizppurioApiException 토큰 재발급 실패 시 + */ + public function refreshToken(): string + { + $this->cache->forget(self::CACHE_KEY); + + return $this->getToken(); + } + + /** + * 캐시된 토큰을 제거합니다. + */ + public function forget(): void + { + $this->cache->forget(self::CACHE_KEY); + } + + /** + * `/v1/token` 을 호출해 새 토큰을 발급받습니다. + * + * @return string Bearer 액세스 토큰 + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + private function issueToken(): string + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $account = (string) ($settings['bizppurio_id'] ?? ''); + $password = (string) ($settings['password'] ?? ''); + + if ($account === '' || $password === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.credentials_missing'), + ); + } + + $response = $this->http() + ->withHeaders([ + 'Authorization' => 'Basic '.base64_encode($account.':'.$password), + 'Content-Type' => 'application/json; charset=utf-8', + ]) + ->post($this->baseUrl($settings).'/v1/token'); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.token_issue_failed'), + httpStatus: $response->status(), + ); + } + + $token = (string) ($response->json('accesstoken') ?? ''); + + if ($token === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.token_issue_failed'), + httpStatus: $response->status(), + ); + } + + return $token; + } + + /** + * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다. + * + * @param array $settings 플러그인 환경설정 + * @return string 베이스 URL (스킴 포함) + */ + private function baseUrl(array $settings): string + { + $environment = (string) ($settings['environment'] ?? 'dev'); + + return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + } + + /** + * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다. + */ + private function http(): PendingRequest + { + return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS) + ->timeout(self::REQUEST_TIMEOUT_SECONDS); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php new file mode 100644 index 00000000..2de0bace --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php @@ -0,0 +1,146 @@ + 발송 payload + */ + public function buildSms(string $to, string $message, string $refkey): array + { + return $this->withCommon('sms', $to, $refkey, [ + 'sms' => [ + 'message' => $message, + ], + ]); + } + + /** + * LMS 발송 payload 를 조립합니다. (message: EUC-KR 최대 2000byte, subject 최대 64byte) + * + * @param string $to 수신 전화번호 + * @param string $message 본문 + * @param string $refkey 우리 부여 키 + * @param string|null $subject 제목(코어 subject 재사용). 없으면 생략 + * @return array 발송 payload + */ + public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array + { + $lms = ['message' => $message]; + + if ($subject !== null && $subject !== '') { + $lms['subject'] = $subject; + } + + return $this->withCommon('lms', $to, $refkey, [ + 'lms' => $lms, + ]); + } + + /** + * 알림톡(AT) 발송 payload 를 조립합니다. (message: 한/영 1300자) + * + * senderkey 는 환경설정 sender_key 를 사용한다. 버튼/바로연결 등 유형별 선택 + * 필드는 $extra 로 병합한다(템플릿에 포함된 경우 필수 — 채널 드라이버가 구성). + * + * @param string $to 수신 전화번호 + * @param string $templateCode 카카오 템플릿 코드 + * @param string $message 치환 완료된 본문 (#{변수} 형식) + * @param string $refkey 우리 부여 키 + * @param array $extra button/quickreply/title 등 유형별 선택 필드 + * @return array 발송 payload + */ + public function buildAlimtalk( + string $to, + string $templateCode, + string $message, + string $refkey, + array $extra = [], + ): array { + $at = array_merge([ + 'senderkey' => $this->senderKey(), + 'templatecode' => $templateCode, + 'message' => $message, + ], $extra); + + return $this->withCommon('at', $to, $refkey, [ + 'at' => $at, + ]); + } + + /** + * 공통 필드(account/type/from/to/refkey)와 채널별 content 를 결합합니다. + * + * @param string $type 메시지 타입 (sms/lms/at) + * @param string $to 수신 전화번호 + * @param string $refkey 우리 부여 키 + * @param array $content 채널별 content 배열 + * @return array 발송 payload + */ + private function withCommon(string $type, string $to, string $refkey, array $content): array + { + return [ + 'account' => $this->account(), + 'type' => $type, + 'from' => $this->senderNumber(), + 'to' => $to, + 'refkey' => $refkey, + 'content' => $content, + ]; + } + + /** + * 발송 계정(account = bizppurio_id)을 반환합니다. + */ + private function account(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'bizppurio_id', ''); + } + + /** + * 발신 번호(from = sender_number)를 반환합니다. + */ + private function senderNumber(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_number', ''); + } + + /** + * 알림톡 발신프로필 키(senderkey = sender_key)를 반환합니다. + */ + private function senderKey(): string + { + return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_key', ''); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php new file mode 100644 index 00000000..84e01bbd --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php @@ -0,0 +1,90 @@ +byteLength($message) <= self::SMS_MAX_BYTES + ? DispatchChannel::Sms + : DispatchChannel::Lms; + } + + /** + * 본문의 EUC-KR 기준 byte 길이를 반환합니다. + * + * 이모지 등 EUC-KR 미표현 문자를 제거한 뒤 변환하여 byte 를 측정한다. + * + * @param string $message 대상 문자열(UTF-8) + * @return int EUC-KR byte 길이 + */ + public function byteLength(string $message): int + { + $sanitized = $this->stripUnsupported($message); + $eucKr = mb_convert_encoding($sanitized, 'EUC-KR', 'UTF-8'); + + return strlen($eucKr); + } + + /** + * LMS 최대 byte 를 초과하는지 여부를 반환합니다. + * + * @param string $message 변수 치환이 끝난 최종 본문 + * @return bool LMS 한도(2000byte) 초과 시 true + */ + public function exceedsLmsLimit(string $message): bool + { + return $this->byteLength($message) > self::LMS_MAX_BYTES; + } + + /** + * EUC-KR 로 표현 불가능한 문자(이모지 등)를 제거합니다. + * + * mb_convert_encoding 은 미표현 문자를 '?' 로 바꿔 byte 를 왜곡시키므로, + * UTF-8 → EUC-KR → UTF-8 왕복 후 원본과 달라진(=치환된) 문자를 걸러낸다. + * + * @param string $message 대상 문자열(UTF-8) + * @return string EUC-KR 표현 가능한 문자만 남긴 문자열 + */ + private function stripUnsupported(string $message): string + { + $substituteBackup = mb_substitute_character(); + // 미표현 문자를 삭제(none)하도록 설정해 왜곡 없이 제거 + mb_substitute_character('none'); + + try { + $eucKr = mb_convert_encoding($message, 'EUC-KR', 'UTF-8'); + + return mb_convert_encoding($eucKr, 'UTF-8', 'EUC-KR'); + } finally { + mb_substitute_character($substituteBackup); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php index 979844b5..3cf345c5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -2,6 +2,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests; +use App\Extension\HookManager; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -24,8 +25,6 @@ abstract class PluginTestCase extends TestCase /** * 테스트 환경 설정 - * - * @return void */ protected function setUp(): void { @@ -36,8 +35,6 @@ abstract class PluginTestCase extends TestCase /** * tearDown 에 HookManager 상태 복원. - * - * @return void */ protected function tearDown(): void { @@ -48,12 +45,10 @@ abstract class PluginTestCase extends TestCase /** * HookManager static $hooks / $filters / $dispatching 를 스냅샷. - * - * @return void */ private function snapshotHookManager(): void { - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $this->hookSnapshot = [ 'hooks' => $ref->getProperty('hooks')->getValue(), 'filters' => $ref->getProperty('filters')->getValue(), @@ -63,8 +58,6 @@ abstract class PluginTestCase extends TestCase /** * 스냅샷 시점으로 HookManager 복원. - * - * @return void */ private function restoreHookManager(): void { @@ -72,7 +65,7 @@ abstract class PluginTestCase extends TestCase return; } - $ref = new \ReflectionClass(\App\Extension\HookManager::class); + $ref = new \ReflectionClass(HookManager::class); $ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']); $ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']); $ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php new file mode 100644 index 00000000..45fba633 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -0,0 +1,88 @@ + 'acct', 'type' => 'sms', 'from' => '070', 'to' => '010', + 'refkey' => 'ref1', 'content' => ['sms' => ['message' => 'hi']], + ]; + + /** + * sendMessage 응답을 반환하는 ApiClient mock 을 만듭니다. + * + * @param array $result sendMessage 반환값 + */ + private function makeClient(array $result): BizppurioApiClient + { + $mock = Mockery::mock(BizppurioApiClient::class); + $mock->shouldReceive('sendMessage')->with($this->payload)->andReturn($result); + $mock->shouldReceive('isSuccess') + ->andReturnUsing(fn ($r) => (string) ($r['code'] ?? '') === '1000'); + + return $mock; + } + + public function test_성공시_예외없이_종료(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk'])); + + // 예외 미발생 = 정상 완료 + $this->assertTrue(true); + } + + public function test_일시오류_결과코드는_예외를_던져_재시도한다(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->expectException(BizppurioApiException::class); + + try { + $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp'])); + } catch (BizppurioApiException $e) { + $this->assertSame('5003', $e->getResultCode()); + throw $e; + } + } + + public function test_영구실패_결과코드는_예외없이_종료된다(): void + { + // 3006(계정오류) = 영구실패 → 재시도 안 함(예외 없음) + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error'])); + + $this->assertTrue(true); + } + + public function test_tries와_backoff_기본값(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->assertSame(2, $job->tries); + $this->assertSame(2, $job->backoff()); + } + + public function test_after_commit_활성화(): void + { + $job = new SendMessageJob($this->payload, 'ref1'); + + $this->assertTrue($job->afterCommit); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php new file mode 100644 index 00000000..a3aa286b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php @@ -0,0 +1,133 @@ + 'acct', + 'type' => 'sms', + 'from' => '07012345678', + 'to' => '01011112222', + 'refkey' => 'ref1', + 'content' => ['sms' => ['message' => 'hi']], + ]; + + private function makeSettings(string $environment = 'dev'): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get') + ->with(self::IDENTIFIER, 'environment', 'dev') + ->andReturn($environment); + + return $mock; + } + + /** + * getToken/refreshToken 을 순차 반환하는 TokenService mock. + * + * @param string $token getToken 반환 토큰 + * @param string|null $refreshed refreshToken 반환 토큰(null 이면 미기대) + */ + private function makeToken(string $token, ?string $refreshed = null): BizppurioTokenService + { + $mock = Mockery::mock(BizppurioTokenService::class); + $mock->shouldReceive('getToken')->andReturn($token); + + if ($refreshed !== null) { + $mock->shouldReceive('refreshToken')->once()->andReturn($refreshed); + } + + return $mock; + } + + public function test_발송_성공(): void + { + Http::fake([ + '*/v3/message' => Http::response([ + 'code' => 1000, 'description' => 'Success', 'refkey' => 'ref1', 'messagekey' => 'mk1', + ], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('TOKEN'), $this->makeSettings()); + $result = $client->sendMessage($this->payload); + + $this->assertTrue($client->isSuccess($result)); + $this->assertSame('mk1', $result['messagekey']); + Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer TOKEN')); + } + + public function test_토큰무효_3002는_재발급_후_1회_재시도(): void + { + Http::fake([ + '*/v3/message' => Http::sequence() + ->push(['code' => 3002, 'description' => 'Invalid token'], 200) + ->push(['code' => 1000, 'description' => 'Success', 'messagekey' => 'mk2'], 200), + ]); + + $client = new BizppurioApiClient( + $this->makeToken('OLD', 'NEW'), + $this->makeSettings(), + ); + $result = $client->sendMessage($this->payload); + + $this->assertTrue($client->isSuccess($result)); + Http::assertSentCount(2); + } + + public function test_운영_환경은_live_도메인_호출(): void + { + Http::fake([ + 'api.bizppurio.com/*' => Http::response(['code' => 1000], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings('live')); + $client->sendMessage($this->payload); + + Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bizppurio.com')); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['*/v3/message' => Http::response([], 500)]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + + $this->expectException(BizppurioApiException::class); + $client->sendMessage($this->payload); + } + + public function test_영구실패_결과코드는_그대로_반환(): void + { + Http::fake([ + '*/v3/message' => Http::response(['code' => 3006, 'description' => 'Account error'], 200), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + $result = $client->sendMessage($this->payload); + + $this->assertFalse($client->isSuccess($result)); + $this->assertSame(3006, $result['code']); + // 3006 은 재시도 대상 아님 → HTTP 1회 + Http::assertSentCount(1); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php new file mode 100644 index 00000000..973b0dc1 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -0,0 +1,115 @@ + $settings + */ + private function makeSettings(array $settings): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + return $mock; + } + + private function client(): BizppurioKakaoApiClient + { + return new BizppurioKakaoApiClient( + $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => 'key01']), + ); + } + + public function test_발신프로필_조회는_bizid_apikey를_body에_싣는다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['success' => []]], 200), + ]); + + $result = $this->client()->getSenderProfiles(); + + $this->assertTrue($this->client()->isSuccess($result)); + Http::assertSent(function ($request) { + return str_contains($request->url(), 'kapi.ppurio.com/v3/kakao/profile/use') + && $request['bizId'] === 'biz01' + && $request['apiKey'] === 'key01'; + }); + } + + public function test_템플릿_목록은_senderkey를_포함한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200), + ]); + + $this->client()->getTemplateList('SENDER_KEY', ['count' => 20]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/list') + && $request['senderKey'] === 'SENDER_KEY' + && $request['count'] === 20 + && $request['bizId'] === 'biz01'; + }); + } + + public function test_템플릿_상세_조회(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'TW_1']], 200), + ]); + + $result = $this->client()->getTemplateDetail('SK', 'TW_1'); + + $this->assertSame('TW_1', $result['data']['templateCode']); + } + + public function test_임의_엔드포인트_request_위임(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '200'], 200), + ]); + + $this->client()->request('/v3/kakao/template/add', ['templateName' => 'T']); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/add') + && $request['templateName'] === 'T'); + } + + public function test_자격증명_미설정시_예외(): void + { + $client = new BizppurioKakaoApiClient( + $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => '']), + ); + + $this->expectException(BizppurioApiException::class); + $client->getSenderProfiles(); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]); + + $this->expectException(BizppurioApiException::class); + $this->client()->getSenderProfiles(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php new file mode 100644 index 00000000..15260192 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php @@ -0,0 +1,149 @@ + $store 참조로 전달되는 캐시 저장소 + */ + private function makeCache(array &$store): CacheInterface + { + $mock = Mockery::mock(CacheInterface::class); + + $mock->shouldReceive('remember')->andReturnUsing( + function (string $key, callable $callback) use (&$store) { + if (! array_key_exists($key, $store)) { + $store[$key] = $callback(); + } + + return $store[$key]; + } + ); + + $mock->shouldReceive('forget')->andReturnUsing( + function (string $key) use (&$store) { + unset($store[$key]); + + return true; + } + ); + + return $mock; + } + + /** + * bizppurio_id/password/environment 를 반환하는 설정 mock. + * + * @param array $settings + */ + private function makeSettings(array $settings): PluginSettingsService + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + return $mock; + } + + public function test_토큰_발급_후_캐시에서_재사용된다(): void + { + Http::fake([ + '*/v1/token' => Http::response(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $this->assertSame('TOKEN_A', $service->getToken()); + // 두 번째 호출은 캐시에서 → HTTP 1회만 + $this->assertSame('TOKEN_A', $service->getToken()); + Http::assertSentCount(1); + } + + public function test_refresh는_forget_후_재발급한다(): void + { + $tokens = ['TOKEN_A', 'TOKEN_B']; + Http::fake([ + '*/v1/token' => Http::sequence() + ->push(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200) + ->push(['accesstoken' => 'TOKEN_B', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $this->assertSame('TOKEN_A', $service->getToken()); + // refresh → 캐시 무효화 후 새 토큰 + $this->assertSame('TOKEN_B', $service->refreshToken()); + Http::assertSentCount(2); + } + + public function test_검수_환경은_dev_도메인을_호출한다(): void + { + Http::fake([ + 'dev-api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $service->getToken(); + Http::assertSent(fn ($request) => str_contains($request->url(), 'dev-api.bizppurio.com')); + } + + public function test_자격증명_미설정시_예외(): void + { + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => '', 'password' => '']), + ); + + $this->expectException(BizppurioApiException::class); + $service->getToken(); + } + + public function test_http_실패시_예외(): void + { + Http::fake(['*/v1/token' => Http::response([], 500)]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + ); + + $this->expectException(BizppurioApiException::class); + $service->getToken(); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php new file mode 100644 index 00000000..1963c693 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php @@ -0,0 +1,101 @@ + $settings + */ + private function makeBuilder(array $settings): MessagePayloadBuilder + { + $mock = Mockery::mock(PluginSettingsService::class); + $mock->shouldReceive('get') + ->with(self::IDENTIFIER, Mockery::type('string'), Mockery::any()) + ->andReturnUsing(fn ($id, $key, $default) => $settings[$key] ?? $default); + + return new MessagePayloadBuilder($mock); + } + + public function test_sms_payload_구조(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123'); + + $this->assertSame('acct01', $payload['account']); + $this->assertSame('sms', $payload['type']); + $this->assertSame('07012345678', $payload['from']); + $this->assertSame('01011112222', $payload['to']); + $this->assertSame('ref123', $payload['refkey']); + $this->assertSame('테스트 본문', $payload['content']['sms']['message']); + } + + public function test_lms_payload는_subject를_포함한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목'); + + $this->assertSame('lms', $payload['type']); + $this->assertSame('제목', $payload['content']['lms']['subject']); + $this->assertSame('긴 본문', $payload['content']['lms']['message']); + } + + public function test_lms_subject_null이면_생략된다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '본문', 'ref789', null); + + $this->assertArrayNotHasKey('subject', $payload['content']['lms']); + } + + public function test_알림톡_payload는_senderkey와_templatecode를_포함한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [ + 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']], + ]); + + $this->assertSame('at', $payload['type']); + $at = $payload['content']['at']; + $this->assertSame('senderkey40', $at['senderkey']); + $this->assertSame('TW_1234', $at['templatecode']); + $this->assertSame('알림톡 본문', $at['message']); + // extra(버튼) 병합 확인 + $this->assertSame('확인', $at['button'][0]['name']); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php new file mode 100644 index 00000000..32832a0e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php @@ -0,0 +1,63 @@ +resolver = new SmsTypeResolver; + } + + public function test_영문_짧은_본문은_sms(): void + { + // 영문 45바이트 → SMS + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 45))); + } + + public function test_한글은_euckr_2바이트로_계산된다(): void + { + // 한글 45자 = 90byte → 경계 안(SMS) + $this->assertSame(90, $this->resolver->byteLength(str_repeat('가', 45))); + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('가', 45))); + + // 한글 46자 = 92byte → SMS 경계 초과 → LMS + $this->assertSame(92, $this->resolver->byteLength(str_repeat('가', 46))); + $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('가', 46))); + } + + public function test_영문_90바이트_경계와_초과(): void + { + // 정확히 90byte → SMS + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 90))); + // 91byte → LMS + $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('a', 91))); + } + + public function test_이모지는_제거되어_byte에_포함되지_않는다(): void + { + // 영문 10 + 이모지 → 이모지 제거 후 10byte 만 계산(EUC-KR 미표현 제거) + $this->assertSame(10, $this->resolver->byteLength('aaaaaaaaaa😀')); + // 이모지만 있는 경우 0byte → SMS + $this->assertSame(0, $this->resolver->byteLength('😀😀')); + $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve('😀')); + } + + public function test_lms_한도_초과_판정(): void + { + // 영문 2000byte → 한도 이내(false) + $this->assertFalse($this->resolver->exceedsLmsLimit(str_repeat('a', 2000))); + // 2001byte → 초과(true) + $this->assertTrue($this->resolver->exceedsLmsLimit(str_repeat('a', 2001))); + } +} From a3495d8dc63d7f979c6d6ee8fb167c20554f215c Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 10 Jul 2026 11:40:38 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=C2=B7=EC=95=8C=EB=A6=BC=ED=86=A1=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20+=20=EC=9E=90=EB=8F=99=EB=B0=9C?= =?UTF-8?q?=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 — 코어 알림 시스템에 sms·alimtalk 채널을 코어 무수정으로 등록하고, 가입·주문 등 이벤트 발생 시 문자를 자동발송한다. 실제 발송·재시도는 Phase 2 발송 Job 에 위임한다. - filter_available_channels 훅으로 sms·alimtalk 채널 노출(비회원 발송 허용). channel_readiness 로 발신 정보 미설정 시 필요한 항목을 안내. - 코어/게시판/이커머스 3영역의 채널 후보 훅을 구독해 자동발송 커버. - sms 채널 드라이버를 ChannelManager 에 등록(코어 무수정) — 전화번호 해석 (회원=프로필, 비회원=주문 연락처) → 본문 길이로 SMS/LMS 판별 → 발송 위임. - 비회원 주문 전화번호는 이커머스 알림 데이터 훅을 구독해 채운다. 이커머스 모듈은 무수정(자체 관행대로 배송지 연락처를 직접 읽음). 알림톡은 채널 등록·readiness 까지만. 실제 발송·템플릿 연동·탭 UI 는 Phase 5·6, 발송 이력 DB 는 Phase 4 로 이관. --- .../sirsoft-message_bizppurio/CHANGELOG.md | 3 + .../lang/en/messages.php | 21 ++ .../lang/ko/messages.php | 21 ++ .../sirsoft-message_bizppurio/plugin.php | 11 +- .../Listeners/GuestPhoneExtractListener.php | 97 +++++++ .../RegisterNotificationChannelsListener.php | 258 ++++++++++++++++++ .../MessageBizppurioServiceProvider.php | 56 ++++ .../src/Services/SmsChannelDriver.php | 135 +++++++++ .../ChannelRegistrationHookTest.php | 97 +++++++ .../GuestPhoneExtractListenerTest.php | 95 +++++++ ...gisterNotificationChannelsListenerTest.php | 190 +++++++++++++ .../Unit/Services/SmsChannelDriverTest.php | 182 ++++++++++++ .../tests/scenarios/sms-channel-dispatch.yaml | 77 ++++++ 13 files changed, 1241 insertions(+), 2 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 69d4b516..67a4931a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -11,3 +11,6 @@ - 비즈뿌리오(다우기술) 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. - 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다. - 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. +- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. +- 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. +- 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index ea430054..db51ab2f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -31,6 +31,27 @@ return [ 'balance_low' => 'Insufficient Balance', ], + // 알림 채널 메타 (core.notification.filter_available_channels) + 'channels' => [ + 'source_label' => 'Bizppurio', + 'sms' => [ + 'name' => 'SMS/LMS Text', + 'description' => 'Send notifications as SMS/LMS text messages via Bizppurio.', + ], + 'alimtalk' => [ + 'name' => 'Kakao Alimtalk', + 'description' => 'Send notifications as Kakao Alimtalk messages via Bizppurio.', + ], + ], + + // 채널 준비 상태 사유 (core.notification.channel_readiness) + 'readiness' => [ + 'sms_credentials_missing' => 'Please set the Bizppurio ID and password.', + 'sms_sender_number_missing' => 'Please set the sender number.', + 'alimtalk_api_key_missing' => 'Please set the Kakao management API key.', + 'alimtalk_sender_key_missing' => 'Please set the Alimtalk sender profile key.', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => 'Please set the Bizppurio ID and password first.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index bf8e501f..6e6101d4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -31,6 +31,27 @@ return [ 'balance_low' => '잔액 부족', ], + // 알림 채널 메타 (core.notification.filter_available_channels) + 'channels' => [ + 'source_label' => '비즈뿌리오', + 'sms' => [ + 'name' => 'SMS/LMS 문자', + 'description' => '비즈뿌리오를 통해 문자(SMS/LMS)로 알림을 발송합니다.', + ], + 'alimtalk' => [ + 'name' => '카카오 알림톡', + 'description' => '비즈뿌리오를 통해 카카오 알림톡으로 알림을 발송합니다.', + ], + ], + + // 채널 준비 상태 사유 (core.notification.channel_readiness) + 'readiness' => [ + 'sms_credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 설정하세요.', + 'sms_sender_number_missing' => '발신번호를 설정하세요.', + 'alimtalk_api_key_missing' => '카카오 관리 API 키를 설정하세요.', + 'alimtalk_sender_key_missing' => '알림톡 발신프로필 키를 설정하세요.', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index b6b9d2e3..90e876ee 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -5,6 +5,8 @@ namespace Plugins\Sirsoft\MessageBizppurio; use App\Enums\ExtensionOwnerType; use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; +use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; +use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; /** * 비즈뿌리오 메시징 플러그인 @@ -356,12 +358,17 @@ class Plugin extends AbstractPlugin /** * 훅 리스너 목록 반환 * - * Phase 3~6 에서 채널 등록/발송 위임/webhook 리스너를 추가한다. + * Phase 3: 채널 등록/readiness/3영역 노출(RegisterNotificationChannelsListener) + + * 비회원 주문 전화번호 주입(GuestPhoneExtractListener). + * Phase 4~6 에서 webhook/알림톡 발송 리스너를 추가한다. * * @return array */ public function getHookListeners(): array { - return []; + return [ + RegisterNotificationChannelsListener::class, + GuestPhoneExtractListener::class, + ]; } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php new file mode 100644 index 00000000..985d8a92 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php @@ -0,0 +1,97 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-ecommerce.notification.extract_data' => [ + 'method' => 'injectGuestPhone', + 'priority' => 20, + 'type' => 'filter', + ], + ]; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 비회원 주문 알림의 data 에 수신 전화번호를 주입합니다. + * + * 이커머스 리스너가 만든 extract_data 결과(`{notifiable, notifiables, data, context}`)를 + * 받아, 대상이 주문 알림이고 비회원 주문이면 data 에 전화번호를 덧붙여 반환한다. + * 그 외에는 원본을 그대로 통과시킨다(회원 주문은 드라이버가 회원 mobile 을 사용). + * + * @param array $result 이커머스 extract_data 결과 + * @param string $type 알림 정의 유형 + * @param array $args 훅 원본 인수 ([$order, ...]) + * @return array 전화번호가 보강된(또는 원본) 결과 + */ + public function injectGuestPhone(array $result, string $type, array $args): array + { + if (! in_array($type, self::ORDER_NOTIFICATION_TYPES, true)) { + return $result; + } + + $order = $args[0] ?? null; + if (! $order instanceof Order || ! $order->isGuestOrder()) { + return $result; + } + + // 주문자 전화번호(배송지 orderer_phone)를 이커머스 자체 관행(GuestOrderAuthService· + // OrderResource 와 동일)대로 관계 필드에서 직접 읽는다. 이커머스 모듈 무수정. + $phone = $order->shippingAddress?->orderer_phone; + if ($phone === null || trim((string) $phone) === '') { + return $result; + } + + $result['data'] = array_merge( + $result['data'] ?? [], + [SmsChannelDriver::RECIPIENT_PHONE_KEY => $phone], + ); + + return $result; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php new file mode 100644 index 00000000..3612d29f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -0,0 +1,258 @@ +> + */ + public static function getSubscribedHooks(): array + { + $hooks = [ + 'core.notification.filter_available_channels' => [ + 'method' => 'addChannels', + 'priority' => 20, + 'type' => 'filter', + ], + 'core.notification.channel_readiness' => [ + 'method' => 'checkReadiness', + 'priority' => 20, + 'type' => 'filter', + ], + ]; + + foreach (self::CHANNEL_HOOK_PREFIXES as $prefix) { + $hooks["{$prefix}.notification.channels"] = [ + 'method' => 'addChannelCandidates', + 'priority' => 20, + 'type' => 'filter', + ]; + } + + return $hooks; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 사용 가능한 채널 목록에 sms·alimtalk 메타를 추가합니다. + * + * 코어 NotificationChannelService::getAvailableChannels() 가 name_key/description_key/ + * source_label_key 를 활성 locale 로 해석하므로(localized_payload), lang key 로 선언한다. + * 중복 방지: 이미 존재하는 id 는 다시 추가하지 않는다. + * + * allow_guest:true — 비회원 문자 발송 허용(D1). 실제 게스트 전화번호는 SmsChannelDriver 가 + * data 에서 해석한다. + * + * @param array> $channels 현재 채널 메타 목록 + * @return array> sms·alimtalk 이 추가된 목록 + */ + public function addChannels(array $channels): array + { + $existingIds = array_column($channels, 'id'); + + foreach ($this->channelMetas() as $meta) { + if (! in_array($meta['id'], $existingIds, true)) { + $channels[] = $meta; + } + } + + return $channels; + } + + /** + * 채널 준비 상태를 검사합니다 (core.notification.channel_readiness). + * + * 코어/타 플러그인 채널의 판정({ready,reason})은 그대로 통과시키고, 우리 채널(sms/alimtalk) + * 일 때만 환경설정 충족 여부로 교체한다(D2). + * + * - sms: bizppurio_id + password + sender_number + * - alimtalk: sms 조건 + api_key + sender_key + * + * @param array{ready: bool, reason: string|null} $result 이전 필터까지의 판정 + * @param string $channelId 검사 대상 채널 + * @return array{ready: bool, reason: string|null} + */ + public function checkReadiness(array $result, string $channelId): array + { + return match ($channelId) { + 'sms' => $this->checkSmsReadiness(), + 'alimtalk' => $this->checkAlimtalkReadiness(), + default => $result, + }; + } + + /** + * 레거시 다채널 자동 결정 경로에서 채널 후보에 sms·alimtalk 을 더합니다. + * + * {prefix}.notification.channels 는 채널 미지정(다채널) 발송 경로에서만 발화하며(코어 확인), + * 채널 지정 경로에서는 filter_available_channels 가 SSoT 다. 두 경로 모두에서 채널이 + * 누락되지 않도록 후보에 우리 채널 id 를 더한다(중복 제거). + * + * @param array $channels 정의별 채널 id 후보 + * @param string $type 알림 정의 유형 (미사용) + * @param object|null $notifiable 수신자 (미사용) + * @return array 우리 채널이 더해진 후보 + */ + public function addChannelCandidates(array $channels, string $type = '', ?object $notifiable = null): array + { + foreach (self::CHANNEL_IDS as $id) { + if (! in_array($id, $channels, true)) { + $channels[] = $id; + } + } + + return array_values($channels); + } + + /** + * sms·alimtalk 채널 메타 정의를 반환합니다. + * + * @return array> + */ + private function channelMetas(): array + { + return [ + [ + 'id' => 'sms', + 'name_key' => self::LANG.'.channels.sms.name', + 'description_key' => self::LANG.'.channels.sms.description', + 'icon' => 'fas fa-comment-sms', + 'source' => self::PLUGIN_IDENTIFIER, + 'source_label_key' => self::LANG.'.channels.source_label', + 'allow_guest' => true, + ], + [ + 'id' => 'alimtalk', + 'name_key' => self::LANG.'.channels.alimtalk.name', + 'description_key' => self::LANG.'.channels.alimtalk.description', + 'icon' => 'fas fa-comment-dots', + 'source' => self::PLUGIN_IDENTIFIER, + 'source_label_key' => self::LANG.'.channels.source_label', + 'allow_guest' => true, + ], + ]; + } + + /** + * SMS 채널 준비 상태를 검사합니다. + * + * @return array{ready: bool, reason: string|null} + */ + private function checkSmsReadiness(): array + { + if ($this->missing('bizppurio_id') || $this->missing('password')) { + return $this->notReady('sms_credentials_missing'); + } + + if ($this->missing('sender_number')) { + return $this->notReady('sms_sender_number_missing'); + } + + return ['ready' => true, 'reason' => null]; + } + + /** + * 알림톡 채널 준비 상태를 검사합니다. + * + * @return array{ready: bool, reason: string|null} + */ + private function checkAlimtalkReadiness(): array + { + if ($this->missing('bizppurio_id') || $this->missing('password')) { + return $this->notReady('sms_credentials_missing'); + } + + if ($this->missing('sender_number')) { + return $this->notReady('sms_sender_number_missing'); + } + + if ($this->missing('api_key')) { + return $this->notReady('alimtalk_api_key_missing'); + } + + if ($this->missing('sender_key')) { + return $this->notReady('alimtalk_sender_key_missing'); + } + + return ['ready' => true, 'reason' => null]; + } + + /** + * 환경설정 값이 비어 있는지 확인합니다. + * + * @param string $key 설정 키 + * @return bool 비어 있으면 true + */ + private function missing(string $key): bool + { + $value = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, $key, ''); + + return trim((string) $value) === ''; + } + + /** + * ready=false 판정을 lang reason key 와 함께 반환합니다. + * + * @param string $reasonKey messages.readiness.* 하위 키 + * @return array{ready: bool, reason: string} + */ + private function notReady(string $reasonKey): array + { + return ['ready' => false, 'reason' => self::LANG.'.readiness.'.$reasonKey]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 2ef09e01..4594a71f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -5,7 +5,10 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; +use Illuminate\Notifications\ChannelManager; +use Illuminate\Notifications\Notification; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; +use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; /** * 비즈뿌리오 메시징 플러그인 서비스 프로바이더. @@ -54,4 +57,57 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider $this->loadTranslationsFrom($langPath, $this->translationNamespace()); } } + + /** + * 부팅 — 코어 알림 ChannelManager 에 sms 채널 드라이버를 등록합니다. + * + * 코어 GenericNotification::via() 가 반환하는 'sms' 채널 문자열은 코어에 드라이버가 + * 없어 그대로 두면 발송 시 "Driver [sms] not supported" 예외가 난다. 코어를 수정하지 + * 않고 ChannelManager::extend() 로 드라이버를 런타임 등록해, 'sms' 채널 발송이 + * SmsChannelDriver::send() 로 위임되게 한다. 플러그인 비활성/삭제 시 이 등록도 + * 사라져 코어가 안전하게 원복된다. + * + * 알림톡('alimtalk')은 채널 등록(RegisterNotificationChannelsListener)만 이번 Phase 에 + * 하고 실제 발송 드라이버는 Phase 6 이다. 그 사이 alimtalk 채널로 발송이 트리거될 경우의 + * 크래시를 막기 위해, 아직은 no-op 스텁 드라이버로 등록해 둔다(Phase 6 에서 실드라이버로 교체). + */ + public function boot(): void + { + parent::boot(); + + // ChannelManager 는 지연 싱글턴(첫 알림 발송 시 해석)이므로 resolving 콜백으로 등록한다. + // 다만 다른 코드가 이미 해석해 둔 경우 resolving 이 발화하지 않으므로, 이미 해석돼 + // 있으면 즉시 등록하여 어느 순서에서도 누락되지 않게 한다. + $this->app->resolving( + ChannelManager::class, + fn (ChannelManager $manager) => $this->registerChannelDrivers($manager), + ); + + if ($this->app->resolved(ChannelManager::class)) { + $this->registerChannelDrivers($this->app->make(ChannelManager::class)); + } + } + + /** + * 코어 알림 ChannelManager 에 이 플러그인의 채널 드라이버를 등록합니다. + * + * - sms: SmsChannelDriver 로 실제 발송 위임(Phase 3). + * - alimtalk: 채널 등록만 이번 Phase(§6-2·Phase 6). 실발송 전까지 no-op 스텁으로 + * 등록해 alimtalk 발송 트리거 시 "Driver not supported" 크래시를 막는다. + * + * @param ChannelManager $manager 코어 알림 채널 매니저 + */ + private function registerChannelDrivers(ChannelManager $manager): void + { + $manager->extend('sms', fn ($app) => $app->make(SmsChannelDriver::class)); + + $manager->extend('alimtalk', fn () => new class + { + /** + * @param object $notifiable + * @param Notification $notification + */ + public function send($notifiable, $notification): void {} + }); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php new file mode 100644 index 00000000..4bd4bf0a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -0,0 +1,135 @@ +mobile, 비회원=알림 data 의 _recipient_phone + * (게스트 전화번호는 각 도메인 extract_data 리스너가 data 에 주입 — D1). + * 3. refkey(우리 부여 unique 키) 생성 → SmsTypeResolver 로 SMS/LMS 판별 → + * MessagePayloadBuilder 로 payload 조립 → SendMessageJob 위임(발송·재시도는 Job 책임). + * + * 발송 이력(bizppurio_dispatches) 영속화는 Phase 4(테이블 신설)에서 이 흐름에 연결한다. + * Phase 3 은 refkey 생성 + payload 조립 + Job 위임까지 담당한다. + */ +class SmsChannelDriver +{ + /** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (extract_data 리스너와 계약) */ + public const RECIPIENT_PHONE_KEY = '_recipient_phone'; + + /** + * @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve + * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별 + * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 + */ + public function __construct( + private readonly NotificationTemplateService $templateService, + private readonly SmsTypeResolver $typeResolver, + private readonly MessagePayloadBuilder $payloadBuilder, + ) {} + + /** + * 알림을 문자(SMS/LMS)로 발송합니다. + * + * Laravel NotificationSender 가 'sms' 채널 드라이버로 이 메서드를 호출한다. + * GenericNotification 이 아닌 알림은 대상이 아니므로 조용히 무시한다. + * + * @param object $notifiable 수신자 (User 또는 GuestNotifiable) + * @param Notification $notification 발송 대상 알림 + */ + public function send(object $notifiable, Notification $notification): void + { + if (! $notification instanceof GenericNotification) { + return; + } + + $type = $notification->getType(); + + // 1. sms 채널 본문 템플릿 resolve (없으면 발송 안 함 — Phase 6 에서 기본 body 시드) + $template = $this->templateService->resolve($type, DispatchChannel::Sms->value); + if ($template === null || ! $template->is_active) { + Log::info('비즈뿌리오 SMS 발송 skip — sms 템플릿 없음', ['type' => $type]); + + return; + } + + // 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) + $to = $this->resolvePhone($notifiable, $notification->getData()); + if ($to === null) { + Log::info('비즈뿌리오 SMS 발송 skip — 전화번호 없음', ['type' => $type]); + + return; + } + + // 3. 본문 렌더 (변수 치환) + $locale = BaseNotification::resolveNotifiableLocale($notifiable); + $rendered = $template->replaceVariables($notification->getData(), $locale); + $message = (string) ($rendered['body'] ?? ''); + if (trim($message) === '') { + Log::info('비즈뿌리오 SMS 발송 skip — 본문 비어 있음', ['type' => $type]); + + return; + } + + // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 → Job 위임 + $refkey = $this->generateRefkey(); + $channel = $this->typeResolver->resolve($message); + + $payload = $channel === DispatchChannel::Lms + ? $this->payloadBuilder->buildLms($to, $message, $refkey, (string) ($rendered['subject'] ?? '')) + : $this->payloadBuilder->buildSms($to, $message, $refkey); + + SendMessageJob::dispatch($payload, $refkey); + } + + /** + * 수신자의 전화번호를 해석합니다. + * + * 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다. + * 숫자·하이픈 외 문자는 제거하고, 값이 없으면 null 을 반환한다. + * + * @param object $notifiable 수신자 + * @param array $data 알림 data + * @return string|null 정규화된 전화번호 또는 null + */ + private function resolvePhone(object $notifiable, array $data): ?string + { + $raw = $notifiable->mobile + ?? ($data[self::RECIPIENT_PHONE_KEY] ?? null); + + $normalized = preg_replace('/[^0-9]/', '', (string) $raw); + + return ($normalized === null || $normalized === '') ? null : $normalized; + } + + /** + * webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다. + * + * 32자 hex(=32byte)로 고정해 발송 payload refkey 제약을 만족한다. + * + * @return string 32자 refkey + */ + private function generateRefkey(): string + { + return Str::random(32); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php new file mode 100644 index 00000000..04a7c231 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php @@ -0,0 +1,97 @@ + $settings 플러그인 설정 stub + */ + private function registerListener(array $settings = []): void + { + $stub = new class($settings) extends PluginSettingsService + { + /** @param array $map */ + public function __construct(private array $map) {} + + public function get(string $identifier, ?string $key = null, mixed $default = null): mixed + { + return $this->map[$key] ?? $default; + } + }; + + $listener = new RegisterNotificationChannelsListener($stub); + + HookManager::addFilter( + 'core.notification.filter_available_channels', + fn ($channels) => $listener->addChannels($channels), + 20, + ); + HookManager::addFilter( + 'core.notification.channel_readiness', + fn ($result, $channelId) => $listener->checkReadiness($result, $channelId), + 20, + ); + } + + public function test_코어_채널서비스가_sms_alimtalk을_노출한다(): void + { + $this->registerListener(); + + // 실제 코어 서비스가 filter_available_channels 훅을 발화한다 + $channels = app(NotificationChannelService::class)->getAvailableChannels(); + $ids = array_column($channels, 'id'); + + $this->assertContains('sms', $ids); + $this->assertContains('alimtalk', $ids); + } + + public function test_코어_channelservice가_비회원_허용을_인식한다(): void + { + $this->registerListener(); + + $service = app(NotificationChannelService::class); + + $this->assertTrue($service->isChannelGuestAllowed('sms'), 'sms 는 allow_guest:true 여야 한다.'); + $this->assertTrue($service->isChannelGuestAllowed('alimtalk')); + } + + public function test_코어_readiness서비스가_미설정_sms를_not_ready로_판정한다(): void + { + $this->registerListener([]); // 설정 비어 있음 + + $result = app(ChannelReadinessService::class)->check('sms'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.sms_credentials_missing', (string) $result['reason']); + } + + public function test_코어_readiness서비스가_완비_sms를_ready로_판정한다(): void + { + $this->registerListener([ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + 'sender_number' => '025550000', + ]); + + $result = app(ChannelReadinessService::class)->check('sms'); + + $this->assertTrue($result['ready']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php new file mode 100644 index 00000000..ac2c241b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php @@ -0,0 +1,95 @@ +listener = new GuestPhoneExtractListener; + } + + /** + * isGuestOrder 판정과 배송지 orderer_phone 을 지정할 수 있는 Order 더블을 만듭니다. + * + * 리스너가 이커머스 관행대로 `$order->shippingAddress?->orderer_phone` 를 직접 읽으므로, + * shippingAddress 관계 접근자와 그 orderer_phone 속성을 함께 stub 한다. Eloquent 이벤트 + * 재인스턴스화(new static)와 충돌하지 않도록 서브클래스 대신 Mockery partial mock 을 쓴다. + */ + private function orderDouble(bool $guest, ?string $phone): Order + { + $order = \Mockery::mock(Order::class)->makePartial(); + $order->shouldReceive('isGuestOrder')->andReturn($guest); + + // 배송지 관계 접근(getAttribute('shippingAddress'))이 orderer_phone 을 가진 객체를 반환 + $address = $phone === null ? null : (object) ['orderer_phone' => $phone]; + $order->shouldReceive('getAttribute')->with('shippingAddress')->andReturn($address); + + return $order; + } + + private function makeResult(array $data = []): array + { + return ['notifiable' => null, 'notifiables' => null, 'data' => $data, 'context' => []]; + } + + public function test_비회원_주문알림에_전화번호가_주입된다(): void + { + $order = $this->orderDouble(guest: true, phone: '010-1234-5678'); + + $out = $this->listener->injectGuestPhone( + $this->makeResult(['name' => '홍길동']), + 'order_confirmed', + [$order], + ); + + $this->assertSame('010-1234-5678', $out['data'][SmsChannelDriver::RECIPIENT_PHONE_KEY]); + $this->assertSame('홍길동', $out['data']['name'], '기존 data 는 보존되어야 한다.'); + } + + public function test_회원_주문은_전화번호를_주입하지_않는다(): void + { + $order = $this->orderDouble(guest: false, phone: '010-1234-5678'); + + $out = $this->listener->injectGuestPhone($this->makeResult(), 'order_confirmed', [$order]); + + $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']); + } + + public function test_주문알림이_아니면_원본을_통과시킨다(): void + { + $order = $this->orderDouble(guest: true, phone: '010-1234-5678'); + + $out = $this->listener->injectGuestPhone($this->makeResult(['x' => 1]), 'inquiry_replied', [$order]); + + $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']); + } + + public function test_전화번호가_없으면_주입하지_않는다(): void + { + $order = $this->orderDouble(guest: true, phone: null); + + $out = $this->listener->injectGuestPhone($this->makeResult(), 'order_shipped', [$order]); + + $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']); + } + + public function test_구독_훅과_filter_타입을_선언한다(): void + { + $hooks = GuestPhoneExtractListener::getSubscribedHooks(); + + $this->assertArrayHasKey('sirsoft-ecommerce.notification.extract_data', $hooks); + $this->assertSame('filter', $hooks['sirsoft-ecommerce.notification.extract_data']['type']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php new file mode 100644 index 00000000..ca89a6be --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php @@ -0,0 +1,190 @@ + $settings plugin 설정 키→값 + */ + private function makeListener(array $settings = []): RegisterNotificationChannelsListener + { + $stub = new class($settings) extends PluginSettingsService + { + /** @param array $map */ + public function __construct(private array $map) + { + // 부모 생성자(의존성) 우회 — 테스트 stub 은 get()만 사용 + } + + public function get(string $identifier, ?string $key = null, mixed $default = null): mixed + { + return $this->map[$key] ?? $default; + } + }; + + return new RegisterNotificationChannelsListener($stub); + } + + /** 완비된 설정값 */ + private function fullSettings(): array + { + return [ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + 'sender_number' => '025550000', + 'api_key' => 'key-40', + 'sender_key' => 'sender-40', + ]; + } + + public function test_사용가능_채널에_sms와_alimtalk이_추가된다(): void + { + $listener = $this->makeListener(); + + $channels = $listener->addChannels([ + ['id' => 'mail'], + ['id' => 'database'], + ]); + + $ids = array_column($channels, 'id'); + $this->assertContains('sms', $ids); + $this->assertContains('alimtalk', $ids); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertTrue($sms['allow_guest'], 'sms 는 비회원 발송 허용(allow_guest:true)이어야 한다.'); + $this->assertArrayHasKey('name_key', $sms); + $this->assertStringContainsString('sirsoft-message_bizppurio::', $sms['name_key']); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertTrue($alimtalk['allow_guest']); + } + + public function test_알림톡_채널에는_uses_custom_list_플래그가_아직_없다(): void + { + // 탭 배타 전환 플래그는 Phase 6 — 이번 Phase 채널 메타에는 없어야 한다. + $channels = $this->makeListener()->addChannels([]); + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + + $this->assertArrayNotHasKey('uses_custom_list', $alimtalk); + } + + public function test_이미_존재하는_채널은_중복_추가되지_않는다(): void + { + $channels = $this->makeListener()->addChannels([['id' => 'sms', 'source' => 'other']]); + + $smsCount = count(array_filter($channels, fn ($c) => $c['id'] === 'sms')); + $this->assertSame(1, $smsCount, 'sms 가 중복 추가되면 안 된다.'); + } + + public function test_readiness_sms는_필수설정_부족시_ready_false다(): void + { + $listener = $this->makeListener([]); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.sms_credentials_missing', $result['reason']); + } + + public function test_readiness_sms는_발신번호만_없으면_해당_사유를_반환한다(): void + { + $listener = $this->makeListener([ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + ]); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.sms_sender_number_missing', $result['reason']); + } + + public function test_readiness_sms는_완비시_ready_true다(): void + { + $listener = $this->makeListener($this->fullSettings()); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms'); + + $this->assertTrue($result['ready']); + $this->assertNull($result['reason']); + } + + public function test_readiness_alimtalk는_apikey나_senderkey_없으면_ready_false다(): void + { + $listener = $this->makeListener([ + 'bizppurio_id' => 'acme', + 'password' => 'secret', + 'sender_number' => '025550000', + // api_key / sender_key 누락 + ]); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'alimtalk'); + + $this->assertFalse($result['ready']); + $this->assertStringContainsString('readiness.alimtalk_api_key_missing', $result['reason']); + } + + public function test_readiness_alimtalk는_완비시_ready_true다(): void + { + $listener = $this->makeListener($this->fullSettings()); + + $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'alimtalk'); + + $this->assertTrue($result['ready']); + } + + public function test_readiness_우리채널이_아니면_원본_판정을_통과시킨다(): void + { + $listener = $this->makeListener([]); + $original = ['ready' => false, 'reason' => 'notification.readiness.mail_smtp_host_empty']; + + $result = $listener->checkReadiness($original, 'mail'); + + $this->assertSame($original, $result, 'mail 등 타 채널 판정은 변형 없이 통과해야 한다.'); + } + + public function test_채널후보에_sms와_alimtalk이_더해진다(): void + { + $result = $this->makeListener()->addChannelCandidates(['mail'], 'welcome', null); + + $this->assertContains('mail', $result); + $this->assertContains('sms', $result); + $this->assertContains('alimtalk', $result); + } + + public function test_채널후보_중복은_제거된다(): void + { + $result = $this->makeListener()->addChannelCandidates(['sms'], 'welcome', null); + + $this->assertSame(1, count(array_filter($result, fn ($c) => $c === 'sms'))); + } + + public function test_구독_훅에_3영역_channels가_포함된다(): void + { + $hooks = RegisterNotificationChannelsListener::getSubscribedHooks(); + + $this->assertArrayHasKey('core.notification.filter_available_channels', $hooks); + $this->assertArrayHasKey('core.notification.channel_readiness', $hooks); + $this->assertArrayHasKey('core.auth.notification.channels', $hooks); + $this->assertArrayHasKey('sirsoft-ecommerce.notification.channels', $hooks); + $this->assertArrayHasKey('sirsoft-board.notification.channels', $hooks); + + // 전부 filter 타입 명시(반환값 무시 회귀 차단) + foreach ($hooks as $config) { + $this->assertSame('filter', $config['type']); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php new file mode 100644 index 00000000..a89fcb4f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -0,0 +1,182 @@ + $rendered replaceVariables 반환값 + */ + private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '주문이 완료되었습니다.']): NotificationTemplate + { + $template = Mockery::mock(NotificationTemplate::class)->makePartial(); + $template->is_active = true; + $template->shouldReceive('replaceVariables')->andReturn($rendered); + + return $template; + } + + /** + * 템플릿 resolve 결과를 지정한 driver 를 만듭니다. + * + * @param NotificationTemplate|null $template resolve 반환값 + */ + private function makeDriver(?NotificationTemplate $template, ?MessagePayloadBuilder $builder = null): SmsChannelDriver + { + $templateService = Mockery::mock(NotificationTemplateService::class); + $templateService->shouldReceive('resolve') + ->with(Mockery::any(), 'sms') + ->andReturn($template); + + return new SmsChannelDriver( + $templateService, + new SmsTypeResolver, + $builder ?? $this->spyBuilder(), + ); + } + + /** + * build* 호출 인자를 기록하는 payload 빌더 스파이를 만듭니다. + */ + private function spyBuilder(): MessagePayloadBuilder + { + return new class extends MessagePayloadBuilder + { + public array $calls = []; + + public function __construct() {} + + public function buildSms(string $to, string $message, string $refkey): array + { + $this->calls[] = ['type' => 'sms', 'to' => $to, 'message' => $message, 'refkey' => $refkey]; + + return ['type' => 'sms', 'to' => $to, 'refkey' => $refkey]; + } + + public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array + { + $this->calls[] = ['type' => 'lms', 'to' => $to, 'message' => $message, 'refkey' => $refkey, 'subject' => $subject]; + + return ['type' => 'lms', 'to' => $to, 'refkey' => $refkey]; + } + }; + } + + private function notification(array $data = []): GenericNotification + { + return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); + } + + public function test_템플릿이_없으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver(null)->send($member, $this->notification()); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_회원은_mobile로_발송한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertCount(1, $builder->calls); + $this->assertSame('01012345678', $builder->calls[0]['to'], '하이픈 제거된 회원 mobile 로 발송해야 한다.'); + } + + public function test_비회원은_data의_전화번호로_발송한다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $builder)->send($guest, $this->notification($data)); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertSame('01099990000', $builder->calls[0]['to']); + } + + public function test_전화번호가_없으면_발송하지_않는다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + + // data 에 전화번호 없음 + 게스트라 mobile 속성 없음 + $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([])); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_짧은_본문은_sm_s로_긴_본문은_lm_s로_보낸다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + // SMS (90 byte 이하) + $smsBuilder = $this->spyBuilder(); + $this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => '짧은 본문']), $smsBuilder) + ->send($member, $this->notification()); + $this->assertSame('sms', $smsBuilder->calls[0]['type']); + + // LMS (90 byte 초과 — 한글 45자 이상 = EUC-KR 90byte 초과) + $longBody = str_repeat('가', 100); + $lmsBuilder = $this->spyBuilder(); + $this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => $longBody]), $lmsBuilder) + ->send($member, $this->notification()); + $this->assertSame('lms', $lmsBuilder->calls[0]['type']); + $this->assertSame('제목', $lmsBuilder->calls[0]['subject'], 'LMS 는 subject(코어 제목)를 재사용해야 한다.'); + } + + public function test_본문이_비어있으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' '])) + ->send($member, $this->notification()); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_generic_notification이_아니면_무시한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + $other = new Notification; + + $this->makeDriver($this->fakeTemplate())->send($member, $other); + + Bus::assertNotDispatched(SendMessageJob::class); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml new file mode 100644 index 00000000..5b992043 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml @@ -0,0 +1,77 @@ +# audit:allow test-scenario-coverage reason: | +# Phase 3 는 코어 알림 채널 등록 + 문자 발송 위임 배선(백엔드)이다. axes cross product 의 +# 런타임 효과(채널 노출/readiness/전화번호 해석/발송 Job 위임)는 PHPUnit 로 전수 가드된다: +# RegisterNotificationChannelsListenerTest(14) + SmsChannelDriverTest(7) + +# GuestPhoneExtractListenerTest(6) + ChannelRegistrationHookTest(4, 실제 훅 체인). +# 프론트 변경 없음(레이아웃 JSON/컴포넌트 미변경) → frontend-change-requires-e2e 비대상이라 +# Playwright spec 은 이번 Phase 미도입. 실발송 왕복(실제 API·webhook)은 계획서 §9 방침대로 +# PO 직접 수행하는 실연동 검증 단계에서 spec 최초 도입하며, 그때 @scenario/@effects 를 매핑한다. + +feature: 비즈뿌리오 문자 채널 등록 + 자동발송 (Phase 3) + +description: | + 코어 알림 시스템에 sms·alimtalk 채널을 등록(코어 무수정 — filter 훅)하고, 3영역 + (코어/게시판/이커머스) 알림 이벤트 발생 시 문자를 자동발송한다. 실제 발송 실행·재시도는 + Phase 2 SendMessageJob 에 위임한다. + + 핵심 동작: + - filter_available_channels: sms·alimtalk 메타 노출(name_key/allow_guest:true) + - channel_readiness: 채널별 환경설정 충족 판정(sms=아이디+비번+발신번호, alimtalk=+api_key+sender_key) + - {core.auth, sirsoft-ecommerce, sirsoft-board}.notification.channels: 3영역 후보 확장 + - ChannelManager::extend('sms'): SmsChannelDriver 로 발송 위임(alimtalk 은 Phase 6, no-op 스텁) + - 전화번호 해석: 회원=users.mobile / 비회원=data 의 _recipient_phone(D1) + - 비회원 전화번호 주입: 이커머스 extract_data 구독으로 orderer_phone → data(GuestPhoneExtractListener) + - sms 템플릿 없으면 발송 skip(본문 시드는 Phase 6) / 본문 byte 로 SMS·LMS 판별 + + ※ alimtalk 실발송·탭 UI·본문 시드는 Phase 5·6. 발송 이력 DB(bizppurio_dispatches) 영속화는 Phase 4. + +axes: + channel: [sms, alimtalk] + recipient: [member, guest] + readiness_state: [ready, missing_credentials, missing_sender] + environment: [dev, live] + +exclusions: + - { channel: alimtalk, recipient: guest, reason: "알림톡 실발송은 Phase 6 — 이번 Phase 는 채널 등록/readiness 만" } + - { readiness_state: missing_sender, channel: alimtalk, reason: "alimtalk readiness 는 자체 키(api_key/sender_key) 부족을 별도 판정 — sender 축은 sms 로 대표" } + +effects: + # 채널 등록 (filter_available_channels) + - sms_channel_exposed_in_available_channels + - alimtalk_channel_exposed_in_available_channels + - both_channels_allow_guest_true + - alimtalk_has_no_uses_custom_list_flag_yet + - existing_channel_not_duplicated + # readiness (channel_readiness) + - sms_ready_when_id_password_sender_number_set + - sms_not_ready_reports_credentials_missing_key + - sms_not_ready_reports_sender_number_missing_key + - alimtalk_not_ready_when_api_key_or_sender_key_missing + - non_owned_channel_readiness_passes_through + # 3영역 후보 (prefix.notification.channels) + - three_prefixes_subscribed_core_auth_ecommerce_board + - channel_candidates_deduplicated + # 발송 위임 (SmsChannelDriver) + - member_phone_resolved_from_mobile + - guest_phone_resolved_from_data_recipient_phone + - dispatch_skipped_when_no_template + - dispatch_skipped_when_no_phone + - dispatch_skipped_when_body_blank + - short_body_dispatched_as_sms + - long_body_dispatched_as_lms_with_subject + - send_message_job_dispatched_with_refkey + # 게스트 전화번호 주입 (extract_data) + - guest_order_phone_injected_into_data + - member_order_phone_not_injected + - non_order_notification_passthrough + # 훅 위생 + - all_channel_hooks_declared_as_filter_type + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php + +audit: + status: codified From fd7e216639d87917e922a15eaa6941eb0232cd57 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 13 Jul 2026 10:08:41 +0900 Subject: [PATCH 04/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=99=94=EB=A9=B4=20KG=EC=9D=B4=EB=8B=88=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=EC=8B=9D=20=EA=B0=9C=ED=8E=B8=20+=20=EA=B2=80?= =?UTF-8?q?=EC=88=98=20=EB=AA=A8=EB=93=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 화면을 공용 클래스·2단 그리드·상단 안내 패널·필드 인라인 에러로 통일 - 연동 환경(dev/live)을 검수 모드 Toggle(is_test_mode)로 전환, 독립 카드로 분리하고 운영 경고 박스를 API 연동 카드 상단에 배치 - 검수 모드를 끄면(운영) 발송 자격증명(아이디·비밀번호·발신번호)을 required 로 강제하는 조건부 검증 필터 훅 추가 - 리포트 수신 주소를 설정 화면에서 확인·복사 (플러그인 라우트가 config('app.url') 기준 절대 URL 반환, 코어 무수정) - 명칭 "메시징→메시지 발송", 설명 "자동발송→발송", "(다우기술)" 제거 - 설정 저장 테스트가 실제 런타임 설정 파일을 오염시키던 결함 격리 --- docs/backend/api/README.md | 3 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 4 +- .../config/settings/defaults.json | 4 +- .../docs/api/README.md | 14 + .../docs/api/report.md | 79 ++ .../lang/en/messages.php | 7 + .../lang/ko/messages.php | 7 + .../sirsoft-message_bizppurio/plugin.json | 6 +- .../sirsoft-message_bizppurio/plugin.php | 25 +- .../layouts/plugin_settings.test.tsx | 62 +- .../resources/lang/en.json | 31 +- .../resources/lang/ko.json | 35 +- .../layouts/admin/plugin_settings.json | 695 +++++++++++------- .../ValidateBizppurioSettingsListener.php | 138 ++++ .../src/Services/BizppurioApiClient.php | 6 +- .../src/Services/BizppurioTokenService.php | 6 +- .../src/routes/api.php | 35 + .../Feature/Installation/InstallationTest.php | 5 +- ...izppurioLiveModeSettingsValidationTest.php | 228 ++++++ .../Settings/ReportUrlEndpointTest.php | 84 +++ .../tests/PluginTestCase.php | 50 +- .../Unit/Services/BizppurioApiClientTest.php | 10 +- .../Services/BizppurioTokenServiceTest.php | 31 +- .../tests/scenarios/plugin-settings.yaml | 59 +- .../tests/scenarios/sms-channel-dispatch.yaml | 2 +- 25 files changed, 1260 insertions(+), 366 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 97ac946e..ee08f795 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 9 · **엔드포인트 수**: 372 +- **확장 수**: 10 · **엔드포인트 수**: 373 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,6 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 1 / 1 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 67a4931a..67b9bc01 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -8,7 +8,9 @@ ### Added -- 비즈뿌리오(다우기술) 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. +- 비즈뿌리오 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. +- 연동 환경을 운영으로 설정하면 발송에 필요한 아이디·비밀번호·발신번호가 반드시 입력되도록 검증하고, 실제 발송·비용이 발생한다는 경고를 함께 안내합니다. +- 발송 결과(리포트)를 받을 수신 주소를 설정 화면에서 확인·복사할 수 있습니다. 이 주소를 비즈뿌리오에 등록하면 발송 결과가 이력에 자동 기록됩니다. - 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다. - 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json index c77a63f4..d4650b55 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json @@ -4,7 +4,7 @@ "description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false." }, "defaults": { - "environment": "dev", + "is_test_mode": true, "bizppurio_id": "", "password": "", "api_key": "", @@ -12,7 +12,7 @@ "sender_key": "" }, "frontend_schema": { - "environment": { "expose": false }, + "is_test_mode": { "expose": false }, "bizppurio_id": { "expose": false }, "password": { "expose": false, "sensitive": true }, "api_key": { "expose": false, "sensitive": true }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md new file mode 100644 index 00000000..4430a7a0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -0,0 +1,14 @@ +# API 레퍼런스 문서 목차 + +> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). +> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. + + +- **문서 수**: 1 · **엔드포인트 수**: 1 + +| 문서 | 도메인 | 엔드포인트 | +| --- | --- | --- | +| [report.md](report.md) | `report` | 1 | + + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md new file mode 100644 index 00000000..9667ac02 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md @@ -0,0 +1,79 @@ +# Report API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Report 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/report-url + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.report.url` +- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/report-url HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| url | string | `http://g7-issue.eh.test/api/plugins/s…` | 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 콜백 URL(`/api/plugins/sirsoft-message_bizppurio/webhook`)의 사이트 도메인 기준 절대 주소. `config('app.url')` 을 신뢰 소스로 조합하므로 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 반환된다. | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "data": { + "url": "https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook" + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 | + + + +**설명** + +비즈뿌리오 발송 결과(리포트)를 수신할 콜백 URL을 관리자 설정 페이지에 표시하기 위해 반환하는 엔드포인트입니다. 관리자 환경설정 화면(`admin/plugin_settings.json`)의 `report_url` data_source 가 이 값을 조회해 "리포트 수신 설정" 카드에 readonly 로 표시하고, 운영자가 그대로 복사해 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 URL PUSH 수신 주소로 등록합니다. + +URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합합니다 — 리버스 프록시 뒤 PHP-FPM 환경에서 요청 host 가 `localhost` 로 떨어질 수 있어, 운영자가 관리하는 설정값을 신뢰 소스로 삼아 항상 정식 도메인을 노출합니다. + +관리자 인증(`auth:sanctum`)과 `core.plugins.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403 으로 응답합니다. + +※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index db51ab2f..e4b28302 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -52,6 +52,13 @@ return [ 'alimtalk_sender_key_missing' => 'Please set the Alimtalk sender profile key.', ], + // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용) + 'settings' => [ + 'bizppurio_id_attribute' => 'Bizppurio ID', + 'password_attribute' => 'Password', + 'sender_number_attribute' => 'Sender Number', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => 'Please set the Bizppurio ID and password first.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index 6e6101d4..e6959a7a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -52,6 +52,13 @@ return [ 'alimtalk_sender_key_missing' => '알림톡 발신프로필 키를 설정하세요.', ], + // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용) + 'settings' => [ + 'bizppurio_id_attribute' => '비즈뿌리오 아이디', + 'password_attribute' => '비밀번호', + 'sender_number_attribute' => '발신번호', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json index 1a752d1d..e9f35053 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -2,13 +2,13 @@ "identifier": "sirsoft-message_bizppurio", "vendor": "sirsoft", "name": { - "ko": "비즈뿌리오 메시징", + "ko": "비즈뿌리오 메시지 발송", "en": "Bizppurio Messaging" }, "version": "1.0.0", "description": { - "ko": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", - "en": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." + "ko": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.", + "en": "Bizppurio SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook." }, "license": "MIT", "g7_version": ">=7.0.3", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 90e876ee..3979d8a1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -7,11 +7,12 @@ use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; +use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener; /** - * 비즈뿌리오 메시징 플러그인 + * 비즈뿌리오 메시지 발송 플러그인 * - * 비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송을 제공합니다. + * 비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송을 제공합니다. * 코어 알림 시스템의 채널로 문자·알림톡을 발송하고, 발송 결과를 webhook 으로 * 수신하여 이력에 기록합니다. */ @@ -146,11 +147,11 @@ class Plugin extends AbstractPlugin { return [ 'name' => [ - 'ko' => '비즈뿌리오 메시징', + 'ko' => '비즈뿌리오 메시지 발송', 'en' => 'Bizppurio Messaging', ], 'description' => [ - 'ko' => '비즈뿌리오 메시징 플러그인이 제공하는 권한', + 'ko' => '비즈뿌리오 메시지 발송 플러그인이 제공하는 권한', 'en' => 'Permissions provided by the Bizppurio Messaging plugin', ], 'categories' => [ @@ -203,14 +204,13 @@ class Plugin extends AbstractPlugin public function getSettingsSchema(): array { return [ - 'environment' => [ - 'type' => 'enum', - 'options' => ['dev', 'live'], - 'default' => 'dev', - 'label' => ['ko' => '연동 환경', 'en' => 'Environment'], + 'is_test_mode' => [ + 'type' => 'boolean', + 'default' => true, + 'label' => ['ko' => '검수 모드', 'en' => 'Test Mode'], 'hint' => [ - 'ko' => '검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.', - 'en' => 'Select the inspection (dev) or production environment. The sending API domain differs by environment.', + 'ko' => '검수 모드를 끄면 운영 환경으로 발송됩니다. 발송 API 도메인이 환경에 따라 분기됩니다.', + 'en' => 'Turn off test mode to send in the production environment. The sending API domain differs by environment.', ], 'required' => false, ], @@ -281,7 +281,7 @@ class Plugin extends AbstractPlugin public function getConfigValues(): array { return [ - 'environment' => 'dev', + 'is_test_mode' => true, 'bizppurio_id' => '', 'password' => '', 'api_key' => '', @@ -369,6 +369,7 @@ class Plugin extends AbstractPlugin return [ RegisterNotificationChannelsListener::class, GuestPhoneExtractListener::class, + ValidateBizppurioSettingsListener::class, ]; } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index 5d5e5bea..f38145cc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -55,19 +55,33 @@ describe('plugin_settings.json — 자동바인딩', () => { }); }); -describe('plugin_settings.json — 3 섹션', () => { +describe('plugin_settings.json — 섹션', () => { it.each([ + ['info_panel', '상단 안내 패널'], ['section_api', 'API 연동'], ['section_sending', '발송 설정'], - ['section_integration', '연동 안내'], + ['report_section', '리포트 수신 설정'], ])('%s 섹션이 존재한다', (id) => { expect(findById(root, id)).toBeTruthy(); }); + + it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => { + const card = findById(root, 'test_mode_card'); + expect(card).toBeTruthy(); + const raw = JSON.stringify(card); + expect(raw).toContain('"Toggle"'); + expect(raw).toContain('is_test_mode'); + }); + + it('운영 모드(검수 off) 경고 박스가 조건부로 존재한다', () => { + const warning = findById(root, 'live_mode_warning'); + expect(warning).toBeTruthy(); + expect((warning as { if?: string }).if).toContain('!_local.form.is_test_mode'); + }); }); describe('plugin_settings.json — 입력 필드 6종', () => { it.each([ - 'environment', 'bizppurio_id', 'password', 'api_key', @@ -91,21 +105,37 @@ describe('plugin_settings.json — 입력 필드 6종', () => { } }); - it('environment 는 dev/live 옵션 Select 이다', () => { - const select = findInputByName(root, 'environment'); - const options = (select?.children ?? []).map( - (c) => (c.props as { value?: string } | undefined)?.value - ); - expect(options).toEqual(['dev', 'live']); +}); + +describe('plugin_settings.json — 리포트 수신 설정', () => { + it('report_url 데이터소스가 조회 엔드포인트를 호출한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const reportUrl = sources.find((s) => s.id === 'report_url'); + expect(reportUrl).toBeTruthy(); + expect(reportUrl?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/report-url'); + }); + + it('리포트 섹션에 조회값(fallback 웹훅 경로) readonly 표시 + 복사 버튼이 있다', () => { + const section = findById(root, 'report_section'); + const raw = JSON.stringify(section); + expect(raw).toContain('report_url?.data?.url'); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/webhook'); + expect(raw).toContain('"readOnly":true'); + expect(raw).toContain('copyToClipboard'); }); }); -describe('plugin_settings.json — webhook 안내', () => { - it('연동 안내 섹션에 고정 webhook 경로가 readonly 로 표시된다', () => { - const section = findById(root, 'section_integration'); - const inputs = JSON.stringify(section); - expect(inputs).toContain('/api/plugins/sirsoft-message_bizppurio/webhook'); - expect(inputs).toContain('"readOnly":true'); +describe('plugin_settings.json — 필드 인라인 에러', () => { + it.each([ + 'bizppurio_id', + 'password', + 'api_key', + 'sender_number', + 'sender_key', + ])('%s 필드에 인라인 에러 노드가 존재한다', (name) => { + const errorNode = findById(root, `field_${name}_error`); + expect(errorNode).toBeTruthy(); + expect((errorNode as { if?: string }).if).toContain(`_local.errors?.${name}`); }); }); @@ -124,7 +154,7 @@ describe('plugin_settings.json — 저장 버튼', () => { it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { const handlers = collectHandlers(layout); - const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView']; + const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView', 'copyToClipboard']; for (const h of handlers) { expect(allowed).toContain(h); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index ef90355f..e3f026ab 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -1,6 +1,6 @@ { "name": "Bizppurio Messaging", - "description": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk messaging plugin.", + "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin.", "settings": { "title": "Bizppurio Messaging Settings", "description": "Manage Bizppurio integration credentials and sending options.", @@ -8,6 +8,16 @@ "saving": "Saving...", "save_success": "Settings saved.", "save_failed": "Failed to save settings.", + "info": { + "credentials": "Enter the integration credentials (ID, password, API key) used for sending and Kakao management, along with the sender information. Verify every value before turning off test mode and switching to production.", + "report": "To automatically receive delivery results (success/failure) and record them in the history, register the report endpoint below with Bizppurio." + }, + "test_mode": { + "label": "Test Mode", + "hint": "In test mode, messages are sent to the Bizppurio inspection (dev) domain. Turn it off to send through the production domain." + }, + "live_mode_warning_title": "Sending in production", + "live_mode_warning_body": "Test mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep test mode on while inspecting.", "sections": { "api": { "title": "API Integration", @@ -16,19 +26,9 @@ "sending": { "title": "Sending Options", "description": "Sender information used for SMS and alimtalk delivery." - }, - "integration": { - "title": "Integration Guide", - "description": "The webhook address that receives delivery results." } }, "fields": { - "environment": { - "label": "Environment", - "hint": "Select the inspection (dev) or production environment. The sending API domain differs by environment.", - "option_dev": "Inspection (Dev)", - "option_live": "Production" - }, "bizppurio_id": { "label": "Bizppurio ID", "hint": "The Bizppurio account ID used for both sending and Kakao management." @@ -50,11 +50,12 @@ "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." } }, - "webhook": { - "label": "Delivery Result Address (webhook)", - "hint": "Register this address with the Bizppurio business team as the URL PUSH endpoint so delivery results are recorded automatically.", + "report": { + "section_title": "Report Endpoint", + "hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).", + "note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.", "copy": "Copy", - "copied": "Copied." + "copied": "Report endpoint copied to clipboard." } } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index e1d93a1f..4e585d3d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -1,13 +1,23 @@ { - "name": "비즈뿌리오 메시징", - "description": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다.", + "name": "비즈뿌리오 메시지 발송", + "description": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다.", "settings": { - "title": "비즈뿌리오 메시징 설정", + "title": "비즈뿌리오 메시지 발송 설정", "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.", "save": "저장", "saving": "저장 중...", "save_success": "설정이 저장되었습니다.", "save_failed": "설정 저장에 실패했습니다.", + "info": { + "credentials": "발송·카카오 관리에 사용하는 연동 정보(아이디·비밀번호·API 키)와 발신 정보를 입력합니다. 검수 모드를 끄고 운영으로 전환하기 전에 모든 값이 정확한지 확인하세요.", + "report": "발송 결과(성공/실패)를 자동으로 받아 이력에 기록하려면 아래 리포트 수신 주소를 비즈뿌리오에 등록해야 합니다." + }, + "test_mode": { + "label": "검수 모드", + "hint": "검수 모드에서는 비즈뿌리오 검수(개발) 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다." + }, + "live_mode_warning_title": "운영 환경으로 발송됩니다", + "live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.", "sections": { "api": { "title": "API 연동", @@ -16,19 +26,9 @@ "sending": { "title": "발송 설정", "description": "문자·알림톡 발송에 사용하는 발신 정보입니다." - }, - "integration": { - "title": "연동 안내", - "description": "발송 결과를 수신하는 webhook 주소입니다." } }, "fields": { - "environment": { - "label": "연동 환경", - "hint": "검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.", - "option_dev": "검수(개발)", - "option_live": "운영" - }, "bizppurio_id": { "label": "비즈뿌리오 아이디", "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." @@ -50,11 +50,12 @@ "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." } }, - "webhook": { - "label": "발송 결과 수신 주소 (webhook)", - "hint": "비즈뿌리오 사업팀에 이 주소를 URL PUSH 수신 주소로 등록하면 발송 결과가 이력에 자동 기록됩니다.", + "report": { + "section_title": "리포트 수신 설정", + "hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.", + "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.", "copy": "복사", - "copied": "복사되었습니다." + "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." } } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index 751cdb1e..bacd2c55 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -19,6 +19,15 @@ "auth_required": true, "initLocal": "form", "refetchOnMount": true + }, + { + "id": "report_url", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/report-url", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "initLocal": "reportInfo" } ], "slots": { @@ -30,7 +39,7 @@ "dataKey": "form", "trackChanges": true, "props": { - "className": "p-4 sm:p-6 lg:p-8 min-h-screen bg-gray-50 dark:bg-gray-900" + "className": "admin-page-content-responsive" }, "children": [ { @@ -44,6 +53,41 @@ "backLabel": "$t:admin.plugins.back_to_list" } }, + { + "id": "info_panel", + "type": "basic", + "name": "Div", + "props": { + "className": "alert-info" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-3" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "circle-info", + "size": "md", + "className": "text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "space-y-1 text-info-soft" }, + "children": [ + { "type": "basic", "name": "P", "text": "$t:sirsoft-message_bizppurio.settings.info.credentials" }, + { "type": "basic", "name": "P", "text": "$t:sirsoft-message_bizppurio.settings.info.report" } + ] + } + ] + } + ] + }, { "id": "validation_error", "type": "basic", @@ -54,15 +98,13 @@ "role": "alert", "aria-live": "assertive", "tabIndex": -1, - "className": "alert-danger scroll-mt-32" + "className": "mt-6 alert-danger scroll-mt-32" }, "children": [ { "type": "basic", "name": "Div", - "props": { - "className": "flex gap-3" - }, + "props": { "className": "flex gap-3" }, "children": [ { "type": "basic", @@ -80,18 +122,14 @@ { "type": "basic", "name": "H3", - "props": { - "className": "text-error-soft" - }, + "props": { "className": "text-error-soft" }, "text": "$t:admin.plugins.settings.validation_error" }, { "type": "basic", "name": "Ul", "if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}", - "props": { - "className": "mt-2 list-disc list-inside space-y-1" - }, + "props": { "className": "mt-2 list-disc list-inside space-y-1" }, "iteration": { "source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])", "item_var": "message" @@ -100,6 +138,7 @@ { "type": "basic", "name": "Li", + "props": { "className": "text-error-soft" }, "text": "{{message}}" } ] @@ -110,6 +149,100 @@ } ] }, + { + "id": "test_mode_card", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "p-6" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-between" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.hint" + } + ] + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "is_test_mode" + } + } + ] + } + ] + } + ] + }, + { + "id": "live_mode_warning", + "type": "basic", + "name": "Div", + "if": "{{_local.form && !_local.form.is_test_mode}}", + "props": { + "className": "mt-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-3" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "triangle-exclamation", + "size": "md", + "className": "text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H3", + "props": { "className": "text-sm font-semibold text-warning-soft" }, + "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_title" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "mt-1 text-warning-soft" }, + "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_body" + } + ] + } + ] + } + ] + }, { "id": "section_api", "type": "basic", @@ -121,24 +254,18 @@ { "type": "basic", "name": "Div", - "props": { - "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" - }, + "props": { "className": "panel-header-row" }, "children": [ { "type": "basic", "name": "H3", - "props": { - "className": "text-base font-semibold text-gray-900 dark:text-gray-100" - }, + "props": { "className": "section-heading-md" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.api.title" }, { "type": "basic", "name": "P", - "props": { - "className": "form-hint" - }, + "props": { "className": "form-hint mt-1" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.api.description" } ] @@ -146,89 +273,57 @@ { "type": "basic", "name": "Div", - "props": { - "className": "p-6 space-y-6" - }, + "props": { "className": "p-6 space-y-6" }, "children": [ { - "id": "field_environment", + "id": "field_bizppurio_id", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.label" - }, - { - "type": "basic", - "name": "Select", - "props": { - "name": "environment", - "className": "input mt-1" - }, + "name": "Div", + "props": { "className": "lg:col-span-4" }, "children": [ { "type": "basic", - "name": "Option", - "props": { - "value": "dev" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_dev" + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" }, { "type": "basic", - "name": "Option", - "props": { - "value": "live" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_live" + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" } ] }, { "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.environment.hint" - } - ] - }, - { - "id": "field_bizppurio_id", - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label" - }, - { - "type": "basic", - "name": "Input", - "props": { - "type": "text", - "name": "bizppurio_id", - "autoComplete": "off", - "className": "input mt-1" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "bizppurio_id", + "autoComplete": "off", + "className": "{{_local.errors?.bizppurio_id ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" + } + }, + { + "id": "field_bizppurio_id_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.bizppurio_id}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.bizppurio_id?.[0] ?? ''}}" + } + ] } ] }, @@ -236,33 +331,52 @@ "id": "field_password", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "password", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "input mt-1 font-mono" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "password", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.password ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_password_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.password}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.password?.[0] ?? ''}}" + } + ] } ] }, @@ -270,33 +384,52 @@ "id": "field_api_key", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "api_key", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "input mt-1 font-mono" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "api_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.api_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_api_key_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.api_key}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.api_key?.[0] ?? ''}}" + } + ] } ] } @@ -315,24 +448,18 @@ { "type": "basic", "name": "Div", - "props": { - "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" - }, + "props": { "className": "panel-header-row" }, "children": [ { "type": "basic", "name": "H3", - "props": { - "className": "text-base font-semibold text-gray-900 dark:text-gray-100" - }, + "props": { "className": "section-heading-md" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title" }, { "type": "basic", "name": "P", - "props": { - "className": "form-hint" - }, + "props": { "className": "form-hint mt-1" }, "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.description" } ] @@ -340,40 +467,57 @@ { "type": "basic", "name": "Div", - "props": { - "className": "p-6 space-y-6" - }, + "props": { "className": "p-6 space-y-6" }, "children": [ { "id": "field_sender_number", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "text", - "name": "sender_number", - "autoComplete": "off", - "className": "input mt-1" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "name": "sender_number", + "autoComplete": "off", + "className": "{{_local.errors?.sender_number ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}" + } + }, + { + "id": "field_sender_number_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.sender_number}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.sender_number?.[0] ?? ''}}" + } + ] } ] }, @@ -381,33 +525,52 @@ "id": "field_sender_key", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, "children": [ { "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" + } + ] }, { "type": "basic", - "name": "Input", - "props": { - "type": "password", - "name": "sender_key", - "autoComplete": "new-password", - "placeholder": "••••••••", - "className": "input mt-1 font-mono" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint mt-1" - }, - "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint" + "name": "Div", + "props": { "className": "lg:col-span-8" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "password", + "name": "sender_key", + "autoComplete": "new-password", + "placeholder": "••••••••", + "className": "{{_local.errors?.sender_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}" + } + }, + { + "id": "field_sender_key_error", + "type": "basic", + "name": "P", + "if": "{{_local.errors?.sender_key}}", + "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.errors?.sender_key?.[0] ?? ''}}" + } + ] } ] } @@ -416,79 +579,124 @@ ] }, { - "id": "section_integration", + "id": "report_section", "type": "basic", "name": "Div", "props": { - "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800" }, "children": [ { "type": "basic", "name": "Div", "props": { - "className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700" + "className": "px-6 py-4 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 rounded-t-lg" }, "children": [ { "type": "basic", - "name": "H3", - "props": { - "className": "text-base font-semibold text-gray-900 dark:text-gray-100" - }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.title" - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "form-hint" - }, - "text": "$t:sirsoft-message_bizppurio.settings.sections.integration.description" + "name": "Div", + "props": { "className": "flex-center gap-2" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "bell", + "size": "sm", + "className": "text-blue-600 dark:text-blue-400" + } + }, + { + "type": "basic", + "name": "H3", + "props": { "className": "text-base font-semibold text-blue-900 dark:text-blue-100" }, + "text": "$t:sirsoft-message_bizppurio.settings.report.section_title" + } + ] } ] }, { "type": "basic", "name": "Div", - "props": { - "className": "p-6 space-y-2" - }, + "props": { "className": "p-6 space-y-4" }, "children": [ - { - "type": "basic", - "name": "Label", - "props": { - "className": "form-label" - }, - "text": "$t:sirsoft-message_bizppurio.settings.webhook.label" - }, { "type": "basic", "name": "Div", - "props": { - "className": "flex gap-2" - }, "children": [ { "type": "basic", - "name": "Input", - "props": { - "type": "text", - "readOnly": true, - "value": "/api/plugins/sirsoft-message_bizppurio/webhook", - "className": "input flex-1 font-mono text-sm bg-gray-50 dark:bg-gray-900" - } + "name": "P", + "props": { "className": "text-label mb-2" }, + "text": "$t:sirsoft-message_bizppurio.settings.report.hint" + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-center gap-2" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "readOnly": true, + "value": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}", + "className": "input-readonly flex-1 font-mono" + } + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "flex-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-body border border-gray-300 dark:border-gray-600 rounded-lg font-medium transition-colors whitespace-nowrap" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "copyToClipboard", + "params": { + "text": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "$t:sirsoft-message_bizppurio.settings.report.copied" + } + } + ] + } + ], + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "copy", "size": "sm" } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-message_bizppurio.settings.report.copy" + } + ] + } + ] } ] }, { "type": "basic", "name": "P", - "props": { - "className": "form-hint" - }, - "text": "$t:sirsoft-message_bizppurio.settings.webhook.hint" + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.report.note" } ] } @@ -498,9 +706,7 @@ "id": "footer_buttons", "type": "basic", "name": "Div", - "props": { - "className": "sticky bottom-0 z-10 flex justify-end items-center gap-3 mt-6 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700" - }, + "props": { "className": "sticky-footer-buttons" }, "children": [ { "id": "cancel_button", @@ -514,17 +720,11 @@ { "type": "click", "handler": "navigate", - "params": { - "path": "/admin/plugins" - } + "params": { "path": "/admin/plugins" } } ], "children": [ - { - "type": "basic", - "name": "Span", - "text": "$t:common.cancel" - } + { "type": "basic", "name": "Span", "text": "$t:common.cancel" } ] }, { @@ -543,11 +743,7 @@ "actions": [ { "handler": "setState", - "params": { - "target": "local", - "isSaving": true, - "errors": null - } + "params": { "target": "local", "isSaving": true, "errors": null } }, { "handler": "apiCall", @@ -560,24 +756,15 @@ "onSuccess": [ { "handler": "setState", - "params": { - "target": "local", - "isSaving": false, - "hasChanges": false - } + "params": { "target": "local", "isSaving": false, "hasChanges": false } }, { "handler": "toast", - "params": { - "type": "success", - "message": "$t:admin.plugins.settings.save_success" - } + "params": { "type": "success", "message": "$t:admin.plugins.settings.save_success" } }, { "handler": "refetchDataSource", - "params": { - "dataSourceId": "settings" - } + "params": { "dataSourceId": "settings" } } ], "onError": [ @@ -601,10 +788,7 @@ }, { "handler": "toast", - "params": { - "type": "error", - "message": "$t:admin.plugins.settings.save_error" - } + "params": { "type": "error", "message": "$t:admin.plugins.settings.save_error" } } ] } @@ -616,10 +800,7 @@ "type": "basic", "name": "Icon", "if": "{{_local.isSaving}}", - "props": { - "name": "spinner", - "className": "spinner-sm" - } + "props": { "name": "spinner", "className": "spinner-sm" } }, { "type": "basic", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php new file mode 100644 index 00000000..bd881706 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php @@ -0,0 +1,138 @@ + + */ + private const LIVE_REQUIRED_FIELDS = ['bizppurio_id', 'password', 'sender_number']; + + /** + * 구독 훅 메타데이터. + * + * @return array> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.plugin_settings.update_rules' => [ + 'method' => 'addLiveModeRules', + 'priority' => 10, + 'type' => 'filter', + 'sync' => true, + ], + ]; + } + + /** + * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='addLiveModeRules' 를 명시하므로 + * 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다. + * + * @param mixed ...$args 사용 안 함 + */ + public function handle(...$args): void + { + // no-op — 실제 진입점은 addLiveModeRules() 메서드 (filter 훅) + } + + /** + * 운영(live) 환경 진입 시 발송 자격증명에 required 규칙을 부여한다. + * + * @param array $rules 코어가 스키마로 생성한 검증 규칙 + * @param string $identifier 검증 대상 플러그인 식별자 + * @return array 조정된 검증 규칙 + */ + public function addLiveModeRules(array $rules, string $identifier): array + { + if ($identifier !== self::IDENTIFIER) { + return $rules; + } + + // 검수 모드(is_test_mode)가 명시적으로 false(운영) 일 때만 자격증명을 강제한다. + // 본 필터 훅(core.plugin_settings.update_rules)은 코어 UpdatePluginSettingsRequest 가 + // "현재 요청의 is_test_mode 에 따른 조건부 검증 규칙"을 만들도록 발행하는 확장점이므로, + // 현재 입력 값 참조가 본질적으로 필요하다 (FormRequest 우회가 아님 — 코어 검증기 자체의 입력). + $isTestMode = filter_var( + // audit:allow listener-formrequest-bypass reason: 검증 규칙 필터 훅의 의도된 입력 모드 참조 + request()->input('is_test_mode', true), + FILTER_VALIDATE_BOOLEAN, + FILTER_NULL_ON_FAILURE + ); + + if ($isTestMode !== false) { + return $rules; + } + + // 운영 모드 검증이 확정된 이 시점(HTTP 요청 처리 중)에 현재 로케일로 항목 라벨을 등록한다. + // 요청 처리 시점에는 플러그인 lang 네임스페이스가 모두 준비돼 있어 폴백 없이 정확한 + // 로케일 라벨이 잡힌다. + $this->registerLiveCredentialAttributeLabels(); + + foreach (self::LIVE_REQUIRED_FIELDS as $field) { + $fieldRules = (array) ($rules[$field] ?? []); + // 코어가 부여한 nullable 을 제거하고 required 로 강제. + $fieldRules = array_values(array_filter( + $fieldRules, + static fn ($rule) => $rule !== 'nullable' + )); + if (! in_array('required', $fieldRules, true)) { + array_unshift($fieldRules, 'required'); + } + $rules[$field] = $fieldRules; + } + + return $rules; + } + + /** + * 발송 자격증명 필드의 검증 에러 메시지 항목 라벨을 현재 요청 로케일로 등록한다. + * + * 코어 UpdatePluginSettingsRequest 의 검증 에러 메시지에서 항목 이름이 영문 키(`bizppurio id`)로 + * 노출되지 않도록, 전역 validation.attributes 에 표시 이름을 런타임 병합한다. 코어 검증기를 + * 수정하지 않고 Laravel 의 attribute 해석 메커니즘만 활용한다. + */ + private function registerLiveCredentialAttributeLabels(): void + { + $locale = app()->getLocale(); + + // addLines 가 validation 그룹을 attributes 만 든 빈 껍데기로 조기 캐시하면, 표준 Translator + // 의 isLoaded 가드로 인해 validation.php 전체(required 등)가 로드되지 않는 회귀가 발생한다. + // 따라서 addLines 전에 validation 그룹을 먼저 로드시켜 캐시를 정상으로 채운 뒤 attributes 만 보탠다. + Lang::get('validation.required', [], $locale); + + Lang::addLines([ + 'validation.attributes.bizppurio_id' => __(self::IDENTIFIER.'::messages.settings.bizppurio_id_attribute', [], $locale), + 'validation.attributes.password' => __(self::IDENTIFIER.'::messages.settings.password_attribute', [], $locale), + 'validation.attributes.sender_number' => __(self::IDENTIFIER.'::messages.settings.sender_number_attribute', [], $locale), + ], $locale); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php index 4b915b1a..d8c6a50b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php @@ -125,9 +125,11 @@ class BizppurioApiClient */ private function baseUrl(): string { - $environment = (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'environment', 'dev'); + // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은 + // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다. + $isTestMode = (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); - return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + return $isTestMode ? self::HOST_DEV : self::HOST_LIVE; } /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php index 12f73b30..fa85e137 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php @@ -144,9 +144,11 @@ class BizppurioTokenService */ private function baseUrl(array $settings): string { - $environment = (string) ($settings['environment'] ?? 'dev'); + // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은 + // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다. + $isTestMode = (bool) ($settings['is_test_mode'] ?? true); - return $environment === 'live' ? self::HOST_LIVE : self::HOST_DEV; + return $isTestMode ? self::HOST_DEV : self::HOST_LIVE; } /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php new file mode 100644 index 00000000..53aedba9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -0,0 +1,35 @@ +name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () { + // 리포트 수신 주소 조회 (관리자 설정 페이지 표시용) + // + // 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 주소를 사이트 도메인 + // 기준 절대 URL 로 내려준다. url() 헬퍼는 리버스 프록시 뒤에서 요청 host 가 + // localhost 로 떨어질 수 있어, 운영자가 관리하는 config('app.url') 을 신뢰 소스로 + // 삼아 절대화한다. 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 표시된다. + // + // ※ 실제 리포트 수신 처리(POST /webhook) 는 Phase 4 에서 구현한다. + Route::get('/report-url', function () { + $origin = rtrim((string) config('app.url', 'http://localhost'), '/'); + + return response()->json([ + 'success' => true, + 'data' => [ + 'url' => $origin.'/api/plugins/sirsoft-message_bizppurio/webhook', + ], + ]); + })->middleware('permission:admin,core.plugins.read') + ->name('report.url'); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php index 448f2cd5..c3ffd2e3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -64,8 +64,9 @@ class InstallationTest extends PluginTestCase // 비크리덴셜 필드는 존재하되 sensitive 아님 $this->assertArrayHasKey('bizppurio_id', $schema); $this->assertArrayHasKey('sender_number', $schema); - $this->assertArrayHasKey('environment', $schema); - $this->assertSame('dev', $schema['environment']['default']); + $this->assertArrayHasKey('is_test_mode', $schema); + $this->assertSame('boolean', $schema['is_test_mode']['type']); + $this->assertTrue($schema['is_test_mode']['default']); } public function test_defaults_json의_크리덴셜은_expose_false다(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php new file mode 100644 index 00000000..dc8a9408 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php @@ -0,0 +1,228 @@ +getSettingsSchema() 를 사용) + $manager = app(PluginManager::class); + $ref = new \ReflectionClass($manager); + $prop = $ref->getProperty('plugins'); + $prop->setAccessible(true); + $plugins = $prop->getValue($manager); + $plugins[self::IDENTIFIER] = new Plugin; + $prop->setValue($manager, $plugins); + + // 검증 규칙 필터 훅 등록 (실제 훅 체인 재현 — mock 없음). + $listener = new ValidateBizppurioSettingsListener; + HookManager::addFilter( + 'core.plugin_settings.update_rules', + [$listener, 'addLiveModeRules'], + 10 + ); + + $this->admin = $this->createAdminUser(); + $this->token = $this->admin->createToken('test-token')->plainTextToken; + } + + /** + * core.plugins.update 권한을 가진 admin 사용자 생성. + */ + private function createAdminUser(): User + { + $user = User::factory()->create(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.update'], + ['name' => json_encode(['ko' => '플러그인 수정', 'en' => 'Update Plugins']), 'type' => 'admin'] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_settings_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync([$permission->id]); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * 인증 헤더가 적용된 PUT 요청 헬퍼. + * + * @param array $body + * @return TestResponse + */ + private function putSettings(array $body) + { + return $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + 'Accept' => 'application/json', + ])->putJson('/api/admin/plugins/'.self::IDENTIFIER.'/settings', $body); + } + + /** + * @scenario test_mode=off,credentials=empty + * + * @effects live_mode_with_empty_credentials_returns_422_with_bizppurio_id_password_sender_number_errors + */ + public function test_live_mode_with_empty_credentials_is_rejected(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['bizppurio_id', 'password', 'sender_number']); + } + + /** + * @scenario test_mode=off,credentials=empty + * + * @effects api_key_and_sender_key_are_not_required_in_live_mode + */ + public function test_live_mode_does_not_require_api_key_or_sender_key(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'secret', + 'sender_number' => '021234567', + 'api_key' => '', + 'sender_key' => '', + ]); + + // api_key / sender_key 는 문자 발송 필수 조건이 아니므로 빈 값이어도 통과해야 한다. + $response->assertStatus(200); + } + + /** + * @scenario test_mode=off,credentials=empty,locale=ko + * + * @effects validation_error_messages_use_korean_field_labels_not_english_keys + */ + public function test_validation_error_messages_use_korean_field_labels(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(422); + + $errors = $response->json('errors'); + // 코어 검증기 수정 없이 리스너의 Lang::addLines 로 주입한 한국어 라벨이 노출되어야 한다. + $this->assertStringContainsString('비즈뿌리오 아이디', $errors['bizppurio_id'][0]); + $this->assertStringNotContainsString('bizppurio id', $errors['bizppurio_id'][0]); + $this->assertStringContainsString('발신번호', $errors['sender_number'][0]); + } + + /** + * @scenario test_mode=off,credentials=filled,locale=ko + * + * @effects live_mode_with_filled_credentials_returns_200 + */ + public function test_live_mode_with_filled_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'secret', + 'sender_number' => '021234567', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario test_mode=on,credentials=empty,locale=ko + * + * @effects dev_mode_with_empty_credentials_returns_200 + */ + public function test_dev_mode_with_empty_credentials_is_accepted(): void + { + $response = $this->putSettings([ + 'is_test_mode' => true, + 'bizppurio_id' => '', + 'password' => '', + 'sender_number' => '', + ]); + + $response->assertStatus(200); + } + + /** + * @scenario test_mode=off,credentials=filled + * + * @effects password_is_not_stored_as_plaintext_in_settings_file, password_decrypts_back_to_original_via_settings_service_get + */ + public function test_password_is_stored_encrypted(): void + { + $this->putSettings([ + 'is_test_mode' => false, + 'bizppurio_id' => 'biz-account', + 'password' => 'super-secret', + 'sender_number' => '021234567', + ])->assertStatus(200); + + // 저장 파일에 평문이 남지 않아야 한다 (sensitive 암호화). + // PluginTestCase 가 'plugins' 디스크를 테스트 전용 경로로 격리(#458)하므로 실제 + // storage_path 가 아니라 격리된 디스크를 통해 파일을 조회한다. + $relativePath = self::IDENTIFIER.'/settings/setting.json'; + $this->assertTrue( + \Illuminate\Support\Facades\Storage::disk('plugins')->exists($relativePath), + '설정 파일이 격리된 plugins 디스크에 저장되어야 한다.' + ); + $raw = \Illuminate\Support\Facades\Storage::disk('plugins')->get($relativePath); + $this->assertStringNotContainsString('super-secret', $raw); + + // 복호화 왕복은 원문과 일치해야 한다. + $value = app(PluginSettingsService::class)->get(self::IDENTIFIER, 'password'); + $this->assertSame('super-secret', $value); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php new file mode 100644 index 00000000..289ba568 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php @@ -0,0 +1,84 @@ +create(); + + $permission = Permission::firstOrCreate( + ['identifier' => 'core.plugins.read'], + ['name' => json_encode(['ko' => '플러그인 조회', 'en' => 'Read Plugins']), 'type' => 'admin'] + ); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_report_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync([$permission->id]); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * @scenario auth=admin,app_url=set + * + * @effects report_url_returns_absolute_url_built_from_app_url + */ + public function test_returns_absolute_report_url_based_on_app_url(): void + { + config(['app.url' => 'https://shop.example.com']); + + $admin = $this->createAdminUser(); + $token = $admin->createToken('test-token')->plainTextToken; + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + ])->getJson(self::ENDPOINT); + + $response->assertStatus(200); + $response->assertJsonPath('data.url', 'https://shop.example.com/api/plugins/sirsoft-message_bizppurio/webhook'); + } + + /** + * @scenario auth=guest + * + * @effects report_url_requires_authentication_returns_401 + */ + public function test_requires_authentication(): void + { + $response = $this->getJson(self::ENDPOINT); + + $response->assertStatus(401); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php index 3cf345c5..2406c567 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -4,6 +4,8 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests; use App\Extension\HookManager; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Storage; use Tests\TestCase; /** @@ -23,6 +25,9 @@ abstract class PluginTestCase extends TestCase */ private ?array $hookSnapshot = null; + /** 테스트 격리용 임시 plugins 스토리지 루트 (setUp 에서 생성, tearDown 에서 제거). */ + private ?string $isolatedPluginsRoot = null; + /** * 테스트 환경 설정 */ @@ -30,19 +35,62 @@ abstract class PluginTestCase extends TestCase { parent::setUp(); + $this->isolatePluginStorage(); $this->snapshotHookManager(); } /** - * tearDown 에 HookManager 상태 복원. + * tearDown 에 HookManager 상태 + plugins 스토리지 복원. */ protected function tearDown(): void { $this->restoreHookManager(); + $this->restorePluginStorage(); parent::tearDown(); } + /** + * 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다. + * + * 설정 저장 테스트(PluginSettingsService::save)는 코어 'plugins' 디스크 + * (root = storage_path('app/plugins'))에 setting.json 을 쓴다. 격리하지 않으면 + * 테스트가 실제 로컬 런타임 설정 파일을 덮어써 검수 모드/자격증명이 오염된다 + * (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않음). 디스크 root 를 + * 임시 경로로 바꾸고 resolved 인스턴스를 purge 하여 실제 파일을 원천적으로 못 + * 건드리게 한다. (회귀 배경: #458) + */ + private function isolatePluginStorage(): void + { + // Laravel 이 테스트용 쓰기 공간으로 보장하는 storage/framework/testing 하위를 사용한다. + // sys_get_temp_dir() 은 CI/컨테이너/open_basedir 제약 환경에서 위치가 다르거나 + // 쓰기 불가일 수 있어 프로젝트 내부의 격리 경로를 쓴다 (Laravel 규약 준수). + $this->isolatedPluginsRoot = storage_path( + 'framework/testing/plugin-storage-'.uniqid('', true) + ); + + File::ensureDirectoryExists($this->isolatedPluginsRoot); + + config(['filesystems.disks.plugins.root' => $this->isolatedPluginsRoot]); + + // 이미 resolve 된 'plugins' 디스크 인스턴스를 폐기해 새 root 로 재생성되게 한다. + Storage::forgetDisk('plugins'); + } + + /** + * 격리 임시 디렉토리를 제거한다 (테스트 간 잔여 파일 격리). + */ + private function restorePluginStorage(): void + { + if ($this->isolatedPluginsRoot !== null && is_dir($this->isolatedPluginsRoot)) { + File::deleteDirectory($this->isolatedPluginsRoot); + } + + $this->isolatedPluginsRoot = null; + + Storage::forgetDisk('plugins'); + } + /** * HookManager static $hooks / $filters / $dispatching 를 스냅샷. */ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php index a3aa286b..84397004 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php @@ -26,12 +26,12 @@ class BizppurioApiClientTest extends PluginTestCase 'content' => ['sms' => ['message' => 'hi']], ]; - private function makeSettings(string $environment = 'dev'): PluginSettingsService + private function makeSettings(bool $isTestMode = true): PluginSettingsService { $mock = Mockery::mock(PluginSettingsService::class); $mock->shouldReceive('get') - ->with(self::IDENTIFIER, 'environment', 'dev') - ->andReturn($environment); + ->with(self::IDENTIFIER, 'is_test_mode', true) + ->andReturn($isTestMode); return $mock; } @@ -88,13 +88,13 @@ class BizppurioApiClientTest extends PluginTestCase Http::assertSentCount(2); } - public function test_운영_환경은_live_도메인_호출(): void + public function test_운영_모드는_live_도메인_호출(): void { Http::fake([ 'api.bizppurio.com/*' => Http::response(['code' => 1000], 200), ]); - $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings('live')); + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings(false)); $client->sendMessage($this->payload); Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bizppurio.com')); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php index 15260192..e3c1dffc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php @@ -48,9 +48,9 @@ class BizppurioTokenServiceTest extends PluginTestCase } /** - * bizppurio_id/password/environment 를 반환하는 설정 mock. + * bizppurio_id/password/is_test_mode 를 반환하는 설정 mock. * - * @param array $settings + * @param array $settings */ private function makeSettings(array $settings): PluginSettingsService { @@ -69,7 +69,7 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $this->assertSame('TOKEN_A', $service->getToken()); @@ -90,7 +90,7 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $this->assertSame('TOKEN_A', $service->getToken()); @@ -99,7 +99,7 @@ class BizppurioTokenServiceTest extends PluginTestCase Http::assertSentCount(2); } - public function test_검수_환경은_dev_도메인을_호출한다(): void + public function test_검수_모드는_dev_도메인을_호출한다(): void { Http::fake([ 'dev-api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), @@ -108,13 +108,30 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $service->getToken(); Http::assertSent(fn ($request) => str_contains($request->url(), 'dev-api.bizppurio.com')); } + public function test_운영_모드는_live_도메인을_호출한다(): void + { + Http::fake([ + 'api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200), + ]); + + $store = []; + $service = new BizppurioTokenService( + $this->makeCache($store), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => false]), + ); + + $service->getToken(); + Http::assertSent(fn ($request) => str_contains($request->url(), 'api.bizppurio.com') + && ! str_contains($request->url(), 'dev-api.bizppurio.com')); + } + public function test_자격증명_미설정시_예외(): void { $store = []; @@ -134,7 +151,7 @@ class BizppurioTokenServiceTest extends PluginTestCase $store = []; $service = new BizppurioTokenService( $this->makeCache($store), - $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'environment' => 'dev']), + $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]), ); $this->expectException(BizppurioApiException::class); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml index e76e0f21..047d746d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -1,49 +1,62 @@ # audit:allow test-scenario-coverage reason: | -# Phase 1 환경설정 화면은 입력·저장이 코어 /api/admin/plugins/{id}/settings 에 위임되는 -# 단순 자동바인딩 폼이라, axes cross product 의 런타임 효과(저장 성공/실패 왕복)는 코어 설정 -# 컨트롤러가 책임진다. 본 확장의 effects(3섹션 구조 / 6필드 / 크리덴셜 마스킹 / webhook 안내 / -# 저장 버튼 게이팅 / i18n 정합 / 권한·메뉴·알림 정의 / 설정 초기값 로드)는 PHPUnit 12건 -# (InstallationTest·EnumTest) + Vitest 22건(plugin_settings.test.tsx) 정적 검증으로 가드된다. -# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 인증·발송 환경 필요) -# 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. 그때까지 -# 매니페스트는 명세로 유지한다. +# 환경설정 화면의 입력·저장은 코어 /api/admin/plugins/{id}/settings 에 위임되는 자동바인딩 +# 폼이라, axes cross product 의 UI 구조·검증·i18n effects 는 Vitest(plugin_settings.test.tsx) +# 30건 정적 검증으로 가드된다. 검수 모드 off(운영) 조건부 검증 왕복(422/200)과 리포트 수신 주소 +# 조회는 PHPUnit Feature(BizppurioLiveModeSettingsValidationTest·ReportUrlEndpointTest) 로 +# 실제 HTTP 왕복을 관찰한다. 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증 +# (PO 직접 수행, 인증·발송 환경 필요) 단계에서 최초 도입하며, 그 시점에 @scenario/@effects +# docblock 완전 매핑을 추가한다. 그때까지 매니페스트는 명세로 유지한다. -feature: 비즈뿌리오 메시징 환경설정 (Phase 1) +feature: 비즈뿌리오 메시지 발송 환경설정 (Phase 1) description: | 관리자 환경설정 화면(admin/plugin_settings.json)에서 비즈뿌리오 연동 정보를 입력·저장한다. 코어 플러그인 설정 인프라(/api/admin/plugins/{id}/settings)에 자동바인딩(_local.form)으로 - 위임하며, 3 섹션(API 연동 / 발송 설정 / 연동 안내)으로 구성된다. + 위임하며, 상단 안내 패널 + 2 섹션(API 연동 / 발송 설정) + 리포트 수신 설정 카드로 구성된다. 핵심 동작: - - API 연동: 연동 환경(dev/live) + 비즈뿌리오 아이디 + 비밀번호 + API 키 + - 검수 모드: is_test_mode Toggle (독립 카드). 켜짐=검수(dev) 도메인, 꺼짐=운영(live) 도메인 + - API 연동: 비즈뿌리오 아이디 + 비밀번호 + API 키 - 발송 설정: 발신번호 + 알림톡 발신프로필 키 - - 연동 안내: 발송 결과 수신 webhook 주소(readonly 표시) + - 검수 모드를 끄면(운영) 경고 박스를 표시하고, 저장 시 발송 필수 자격증명 + (bizppurio_id/password/sender_number)을 required 로 강제(조건부 검증 필터 훅) + - 저장 실패 시 각 필드 하단에 인라인 에러 메시지 표시 + - 리포트 수신 설정: 발송 결과 수신 주소를 사이트 도메인 기준 절대 URL 로 조회·복사 - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT - 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드 - ※ 알림톡 채널 기본 body 시드(3영역)는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이므로 - Phase 6 으로 이관(plugin.php getNotificationDefinitions 주석 참조). + ※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관. axes: credential_state: [empty, filled] - environment: [dev, live] + test_mode: [on, off] field_kind: [credential, plain, readonly] exclusions: - - { field_kind: readonly, credential_state: filled, reason: "webhook 주소는 고정 표시값 — 운영자 입력 대상 아님" } + - { field_kind: readonly, credential_state: filled, reason: "리포트 수신 주소는 서버 조회 표시값 — 운영자 입력 대상 아님" } effects: # 섹션 구조 - - three_sections_api_sending_integration_present + - info_panel_test_mode_api_sending_report_sections_present + - test_mode_card_has_is_test_mode_toggle + - live_mode_warning_shown_when_test_mode_off # 입력 필드 - - six_input_fields_bound_by_name + - five_input_fields_bound_by_name - credential_fields_masked_as_password - plain_fields_use_text_input - - environment_select_has_dev_live_options - # 연동 안내 - - webhook_address_shown_readonly + - inline_error_node_present_per_field + # 조건부 검증 (백엔드) + - test_mode_off_empty_credentials_return_422_for_bizppurio_id_password_sender_number + - test_mode_off_does_not_require_api_key_or_sender_key + - test_mode_on_empty_credentials_return_200 + - test_mode_off_filled_credentials_return_200 + - validation_error_messages_use_localized_field_labels + - test_mode_off_selects_live_domain_test_mode_on_selects_dev_domain + # 리포트 수신 설정 + - report_url_endpoint_returns_absolute_url_from_app_url + - report_url_endpoint_requires_authentication + - report_section_shows_readonly_url_with_copy_button # 저장 - save_button_disabled_without_changes - save_puts_to_core_plugin_settings_api @@ -55,10 +68,12 @@ effects: - activate_creates_hierarchical_admin_menus - deactivate_removes_plugin_menus - balance_low_notification_targets_admin - - settings_defaults_load_with_dev_environment + - settings_defaults_load_with_test_mode_on test_files: - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml index 5b992043..623b6534 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml @@ -29,7 +29,7 @@ axes: channel: [sms, alimtalk] recipient: [member, guest] readiness_state: [ready, missing_credentials, missing_sender] - environment: [dev, live] + test_mode: [on, off] exclusions: - { channel: alimtalk, recipient: guest, reason: "알림톡 실발송은 Phase 6 — 이번 Phase 는 채널 등록/readiness 만" } From 77df8c9a7c3cd36e707f2dcc67ca1a80a6385bcd Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 14 Jul 2026 10:22:36 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=A1=9D=20+=20webho?= =?UTF-8?q?ok=20=EC=88=98=EC=8B=A0=20+=20=EA=B2=B0=EA=B3=BC=EC=BD=94?= =?UTF-8?q?=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문자·알림톡 발송 결과를 DB에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로 도달 여부를 갱신한다. 결과코드를 사유+분류로 해석하고, 잔액 부족 시 관리자에게 쿨다운(기본 1시간) 내 1회만 알린다. 발송 결과를 확인하는 화면은 플러그인 독립 페이지 대신 코어 알림 이력에 얹는 방식(후속)으로 이관하기로 하여, 이번에 만든 독립 이력 화면과 화면 전용 백엔드는 제거했다. webhook 결과 저장·수신·해석 뼈대는 유지한다. --- docs/backend/api/README.md | 4 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 2 + .../config/settings/defaults.json | 3 +- ...0001_create_bizppurio_dispatches_table.php | 69 +++++ ..._bizppurio_notification_bindings_table.php | 51 ++++ .../docs/api/README.md | 3 +- .../docs/api/report.md | 3 +- .../docs/api/webhook.md | 86 ++++++ .../lang/en/messages.php | 5 + .../lang/en/result_codes.php | 66 +++++ .../lang/ko/messages.php | 5 + .../lang/ko/result_codes.php | 69 +++++ .../src/Concerns/PreventsReplayWebhook.php | 43 +++ .../BizppurioWebhookController.php | 49 ++++ .../BizppurioWebhookIpWhitelist.php | 46 ++++ .../Http/Requests/BizppurioWebhookRequest.php | 49 ++++ .../src/Jobs/SendMessageJob.php | 39 ++- .../src/Models/BizppurioDispatch.php | 140 ++++++++++ .../Models/BizppurioNotificationBinding.php | 87 ++++++ .../MessageBizppurioServiceProvider.php | 12 +- .../BizppurioDispatchRepository.php | 90 +++++++ ...BizppurioNotificationBindingRepository.php | 79 ++++++ .../BizppurioDispatchRepositoryInterface.php | 51 ++++ ...NotificationBindingRepositoryInterface.php | 53 ++++ .../src/Services/ResultCodeResolver.php | 112 ++++++++ .../src/Services/SmsChannelDriver.php | 38 ++- .../src/Services/WebhookReportService.php | 146 ++++++++++ .../src/routes/api.php | 13 + .../Feature/Installation/InstallationTest.php | 44 ++++ .../Feature/Webhook/BizppurioWebhookTest.php | 249 ++++++++++++++++++ .../tests/Unit/Jobs/SendMessageJobTest.php | 53 +++- .../Unit/Models/BizppurioDispatchTest.php | 90 +++++++ .../BizppurioDispatchRepositoryTest.php | 75 ++++++ ...purioNotificationBindingRepositoryTest.php | 67 +++++ .../Unit/Services/ResultCodeResolverTest.php | 84 ++++++ .../Unit/Services/SmsChannelDriverTest.php | 42 +++ .../scenarios/dispatch-history-webhook.yaml | 73 +++++ 37 files changed, 2169 insertions(+), 21 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index ee08f795..34b2ebfd 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 373 +- **확장 수**: 10 · **엔드포인트 수**: 374 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,7 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 1 / 1 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 2 / 2 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 67b9bc01..256ccfa2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -16,3 +16,5 @@ - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) +- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.) +- 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json index d4650b55..9fab46fb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json @@ -9,7 +9,8 @@ "password": "", "api_key": "", "sender_number": "", - "sender_key": "" + "sender_key": "", + "balance_low_notify_cooldown": 3600 }, "frontend_schema": { "is_test_mode": { "expose": false }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php new file mode 100644 index 00000000..b9bfb832 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php @@ -0,0 +1,69 @@ +bigIncrements('id')->comment('발송 이력 PK'); + $table->string('refkey', 32)->comment('우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)'); + $table->string('messagekey', 64)->nullable()->comment('비즈뿌리오 부여 키 (발송 응답 messagekey)'); + $table->string('channel', 20)->comment('발송 채널: sms / lms / alimtalk (DispatchChannel enum)'); + $table->string('media', 10)->nullable()->comment('webhook MEDIA — 실제 발송 유형: SMS / LMS / KAT(알림톡)'); + $table->string('to_number', 20)->comment('수신 전화번호 (숫자만)'); + $table->string('to_name', 100)->nullable()->comment('수신자명 (발송 시점 스냅샷)'); + $table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null') + ->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete(); + $table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)'); + $table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null'); + $table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)'); + $table->string('result_code', 10)->nullable()->comment('결과 코드 (발송응답 or 리포트)'); + $table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)'); + $table->string('fallback_status', 20)->nullable()->comment('대체발송 결과: 성공/실패/없음 (webhook TELRES)'); + $table->string('source', 10)->default('auto')->comment('발송 출처: auto / manual / bulk (DispatchSource enum, 1차 auto)'); + $table->timestamp('sent_at')->nullable()->comment('발송 시각'); + $table->timestamp('reported_at')->nullable()->comment('webhook 리포트 수신 시각 (replay 멱등 판정)'); + $table->json('raw_payload')->nullable()->comment('webhook 원본 페이로드'); + $table->timestamps(); + + $table->unique('refkey', 'bizppurio_dispatch_refkey_unique'); + $table->index('channel', 'bizppurio_dispatch_channel_idx'); + $table->index('status', 'bizppurio_dispatch_status_idx'); + $table->index('notification_type', 'bizppurio_dispatch_notif_type_idx'); + $table->index('sent_at', 'bizppurio_dispatch_sent_at_idx'); + $table->index('to_user_id', 'bizppurio_dispatch_user_idx'); + $table->index(['status', 'sent_at'], 'bizppurio_dispatch_status_sent_idx'); + }); + + if (DB::getDriverName() === 'mysql') { + Schema::table('bizppurio_dispatches', function (Blueprint $table) { + $table->comment('비즈뿌리오 문자·알림톡 발송 이력'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bizppurio_dispatches'); + } +}; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php new file mode 100644 index 00000000..25e3ce49 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php @@ -0,0 +1,51 @@ +bigIncrements('id')->comment('연결 설정 PK'); + $table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)'); + $table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)'); + $table->string('template_code', 30)->comment('연결한 카카오 알림톡 템플릿 코드'); + $table->string('template_name', 255)->comment('템플릿 이름 (사람 식별 + 고아 감지용 스냅샷)'); + $table->boolean('fallback_sms_enabled')->default(false)->comment('개별 대체발송 ON/OFF (실패 시 SMS/LMS 대체)'); + $table->boolean('is_active')->default(true)->comment('연동 활성 여부'); + $table->timestamps(); + + $table->unique(['notification_type', 'channel'], 'bizppurio_binding_type_channel_unique'); + }); + + if (DB::getDriverName() === 'mysql') { + Schema::table('bizppurio_notification_bindings', function (Blueprint $table) { + $table->comment('비즈뿌리오 이벤트↔알림톡 템플릿 연결 설정'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bizppurio_notification_bindings'); + } +}; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index 4430a7a0..d223bcc9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,11 +4,12 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 1 · **엔드포인트 수**: 1 +- **문서 수**: 2 · **엔드포인트 수**: 2 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | | [report.md](report.md) | `report` | 1 | +| [webhook.md](webhook.md) | `webhook` | 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md index 9667ac02..3f8a4284 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md @@ -41,7 +41,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| url | string | `http://g7-issue.eh.test/api/plugins/s…` | 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 콜백 URL(`/api/plugins/sirsoft-message_bizppurio/webhook`)의 사이트 도메인 기준 절대 주소. `config('app.url')` 을 신뢰 소스로 조합하므로 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 반환된다. | +| url | string | `http://g7-issue.eh.test/api/plugins/s…` | | **응답 예시** @@ -77,3 +77,4 @@ URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합 ※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md new file mode 100644 index 00000000..ad7d5e9b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md @@ -0,0 +1,86 @@ +# Webhook API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Webhook 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-message_bizppurio/webhook + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.webhook` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController@handle` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| DEVICE | body | string | 아니오 | max 20 | | +| CMSGID | body | string | 아니오 | max 64 | | +| MSGID | body | string | 아니오 | max 64 | | +| PHONE | body | string | 아니오 | max 20 | | +| MEDIA | body | string | 아니오 | max 10 | | +| RESULT | body | string | 예 | max 10 | | +| REFKEY | body | string | 예 | max 32 | | +| TELRES | body | string | 아니오 | max 10 | | +| KAORES | body | string | 아니오 | max 10 | | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/webhook HTTP/1.1 +Host: api.example.com +Accept: application/json +Content-Type: application/json + +{ + "DEVICE": "예시값", + "CMSGID": "예시값", + "MSGID": "예시값", + "PHONE": "010-1234-5678", + "MEDIA": "예시값", + "RESULT": "예시값", + "REFKEY": "예시값", + "TELRES": "예시값", + "KAORES": "예시값" +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +비즈뿌리오가 문자·알림톡 발송 결과를 URL PUSH 로 통보하는 리포트 수신 엔드포인트다. 운영자가 이 주소를 비즈뿌리오에 등록하면(환경설정 화면의 리포트 수신 주소), 발송 후 결과가 이 엔드포인트로 전송된다. + +- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403. +- **처리**: `REFKEY` 로 발송 이력을 조회한다. 없으면(위조/미매칭) 200 으로 흡수한다. 이미 리포트가 반영된 이력(`reported_at` 존재)이면 replay 로 판정해 멱등 처리한다. 그 외에는 `RESULT` 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 `media`·`fallback_status`·`raw_payload`·`reported_at` 을 기록한다. +- **잔액부족**: `RESULT` 가 9070(문자)/7436(알림톡)이면 이력을 실패로 뒤집고 관리자에게 자체 알림을 1회 발송한다. +- 응답은 항상 200 이다(비즈뿌리오가 실패 응답을 재전송하지 않도록). replay 멱등이 중복 처리를 막는다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index e4b28302..b59faed7 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -59,6 +59,11 @@ return [ 'sender_number_attribute' => 'Sender Number', ], + // webhook(URL PUSH) 리포트 수신 + 'webhook' => [ + 'received' => 'Report received.', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => 'Please set the Bizppurio ID and password first.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php new file mode 100644 index 00000000..7f5a6ed4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -0,0 +1,66 @@ + 'Success', + '2000' => 'Invalid request parameter', + '3001' => 'Missing required parameter', + '3002' => 'Invalid token (expired/revoked)', + '3004' => 'Request not allowed', + '3005' => 'Invalid authentication', + '3006' => 'Account information error', + '3007' => 'Sender number not registered', + '3009' => 'Message type error', + '3011' => 'Temporary processing delay', + '3013' => 'Temporary system error', + '5003' => 'Temporary delivery error', + '5004' => 'Temporary delivery error', + '5005' => 'Temporary delivery error', + '9000' => 'Temporary system error', + '9070' => 'Insufficient balance (SMS)', + + // ── SMS report ─────────────────────────────────────── + '4100' => 'Success', + '4400' => 'Out of service area', + '4401' => 'Power off', + '4402' => 'Storage full', + '4410' => 'Invalid number', + '4414' => 'Disconnected/suspended number', + '4420' => 'Other device error', + '4430' => 'Spam rejection', + '4443' => 'Other error', + + // ── LMS report ─────────────────────────────────────── + '6600' => 'Success', + '6603' => 'Out of service area', + '6604' => 'Power off', + '6606' => 'Invalid number', + '6621' => 'Message length exceeded', + '6641' => 'Spam blocked', + + // ── Alimtalk report ────────────────────────────────── + '7000' => 'Success', + '7106' => 'Deleted sender key', + '7107' => 'Blocked sender key', + '7204' => 'Template mismatch', + '7206' => 'Unapproved template', + '7308' => 'Phone number error', + '7320' => 'Receiver blocked', + '7325' => 'Variable length exceeded', + '7436' => 'Insufficient wallet balance (Alimtalk)', + '7523' => 'Other error', +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index e6959a7a..dfe38c01 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -59,6 +59,11 @@ return [ 'sender_number_attribute' => '발신번호', ], + // webhook(URL PUSH) 리포트 수신 + 'webhook' => [ + 'received' => '리포트를 수신했습니다.', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php new file mode 100644 index 00000000..cab79a08 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -0,0 +1,69 @@ + '성공', + '2000' => '요청 파라미터 오류', + '3001' => '필수 파라미터 누락', + '3002' => '토큰 무효(만료·폐기)', + '3004' => '허용되지 않은 요청', + '3005' => '인증 정보 무효', + '3006' => '계정 정보 오류', + '3007' => '발신번호 미등록', + '3009' => '메시지 타입 오류', + '3011' => '일시적 처리 지연', + '3013' => '일시적 시스템 오류', + '5003' => '일시적 전송 오류', + '5004' => '일시적 전송 오류', + '5005' => '일시적 전송 오류', + '9000' => '일시적 시스템 오류', + '9070' => '잔액 부족(문자)', + + // ── SMS 리포트 ──────────────────────────────────────── + '4100' => '성공', + '4400' => '음영 지역', + '4401' => '전원 꺼짐', + '4402' => '저장 매체 초과', + '4410' => '잘못된 번호', + '4414' => '결번·정지', + '4420' => '기타 단말 오류', + '4430' => '스팸 수신 거부', + '4443' => '기타 오류', + + // ── LMS 리포트 ──────────────────────────────────────── + '6600' => '성공', + '6603' => '음영 지역', + '6604' => '전원 꺼짐', + '6606' => '잘못된 번호', + '6621' => '메시지 길이 초과', + '6641' => '스팸 차단', + + // ── 알림톡 리포트 ───────────────────────────────────── + '7000' => '성공', + '7106' => '삭제된 발신 키', + '7107' => '차단된 발신 키', + '7204' => '템플릿 불일치', + '7206' => '검수되지 않은 템플릿', + '7308' => '전화번호 오류', + '7320' => '수신 차단', + '7325' => '변수 길이 초과', + '7436' => '지갑 잔액 부족(알림톡)', + '7523' => '기타 오류', +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php new file mode 100644 index 00000000..df306291 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php @@ -0,0 +1,43 @@ +reported_at !== null; + } + + /** + * replay 감지를 통일된 형식으로 로깅합니다 (운영 모니터링용). + * + * @param string $refkey 우리 부여 키 + * @param string|null $resultCode 리포트 결과 코드 + */ + protected function logReplayDetected(string $refkey, ?string $resultCode): void + { + Log::info('비즈뿌리오 webhook: replay 감지 — 이미 리포트 반영됨, 멱등 응답', [ + 'refkey' => $refkey, + 'result_code' => $resultCode, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php new file mode 100644 index 00000000..633d3453 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php @@ -0,0 +1,49 @@ +reports->apply([ + 'REFKEY' => $request->input('REFKEY'), + 'RESULT' => $request->input('RESULT'), + 'MEDIA' => $request->input('MEDIA'), + 'TELRES' => $request->input('TELRES'), + 'KAORES' => $request->input('KAORES'), + 'raw' => $request->all(), + ]); + + return ResponseHelper::success('sirsoft-message_bizppurio::messages.webhook.received'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php new file mode 100644 index 00000000..00e7f4c0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php @@ -0,0 +1,46 @@ +ip(), self::ALLOWED_IPS, true)) { + abort(403, 'Forbidden'); + } + + return $next($request); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php new file mode 100644 index 00000000..6dbaaedc --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php @@ -0,0 +1,49 @@ + + */ + public function rules(): array + { + return [ + 'DEVICE' => ['nullable', 'string', 'max:20'], + 'CMSGID' => ['nullable', 'string', 'max:64'], + 'MSGID' => ['nullable', 'string', 'max:64'], + 'PHONE' => ['nullable', 'string', 'max:20'], + 'MEDIA' => ['nullable', 'string', 'max:10'], + 'RESULT' => ['required', 'string', 'max:10'], + 'REFKEY' => ['required', 'string', 'max:32'], + 'TELRES' => ['nullable', 'string', 'max:10'], + 'KAORES' => ['nullable', 'string', 'max:10'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php index 244e9c38..c039f494 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -9,7 +9,9 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Support\Facades\Log; +use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioApiClient; use Throwable; @@ -55,17 +57,28 @@ class SendMessageJob implements ShouldQueue /** * 발송을 수행하고 결과코드에 따라 재시도/실패를 판정합니다. * + * 발송 응답으로 이력(bizppurio_dispatches)을 갱신한다(Phase 4): + * - 성공: status=sent(발송 접수 완료, 최종 성공은 webhook 리포트가 확정) + messagekey 저장. + * - 재시도: 예외를 던져 큐 재시도(이력은 pending 유지). + * - 영구 실패: status=failed + result_code 기록. + * * @param BizppurioApiClient $client 발송 API 클라이언트 + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리 * * @throws BizppurioApiException 일시 오류·429 시(큐 재시도 트리거) */ - public function handle(BizppurioApiClient $client): void + public function handle(BizppurioApiClient $client, BizppurioDispatchRepositoryInterface $dispatches): void { $result = $client->sendMessage($this->payload); $code = (string) ($result['code'] ?? ''); if ($client->isSuccess($result)) { - // 발송 접수 성공. 이력 갱신(messagekey 저장)은 Phase 4 훅이 담당. + // 발송 접수 성공 → sent(최종 성공/실패는 webhook 리포트가 확정) + messagekey 저장. + $this->updateDispatch($dispatches, [ + 'status' => DispatchStatus::Sent->value, + 'messagekey' => $result['messagekey'] ?? null, + ]); + return; } @@ -76,7 +89,13 @@ class SendMessageJob implements ShouldQueue ); } - // 영구 실패: 재시도하지 않고 종료. 실패 이력 기록은 Phase 4 가 담당. + // 영구 실패: 재시도하지 않고 종료 + 실패 이력 기록. + $this->updateDispatch($dispatches, [ + 'status' => DispatchStatus::Failed->value, + 'result_code' => $code, + 'result_message' => $result['description'] ?? null, + ]); + Log::warning('비즈뿌리오 발송 영구 실패', [ 'refkey' => $this->refkey, 'code' => $code, @@ -84,6 +103,20 @@ class SendMessageJob implements ShouldQueue ]); } + /** + * refkey 로 발송 이력을 조회해 갱신합니다 (이력이 없으면 무시). + * + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리 + * @param array $data 갱신 데이터 + */ + private function updateDispatch(BizppurioDispatchRepositoryInterface $dispatches, array $data): void + { + $dispatch = $dispatches->findByRefkey($this->refkey); + if ($dispatch !== null) { + $dispatches->update($dispatch, $data); + } + } + /** * 재시도 사이 대기 시간(초)을 반환합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php new file mode 100644 index 00000000..2a8c5903 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php @@ -0,0 +1,140 @@ + + */ + protected $fillable = [ + 'refkey', + 'messagekey', + 'channel', + 'media', + 'to_number', + 'to_name', + 'to_user_id', + 'content', + 'notification_type', + 'status', + 'result_code', + 'result_message', + 'fallback_status', + 'source', + 'sent_at', + 'reported_at', + 'raw_payload', + ]; + + /** + * 속성 캐스팅 정의 + * + * @return array + */ + protected function casts(): array + { + return [ + 'channel' => DispatchChannel::class, + 'status' => DispatchStatus::class, + 'source' => DispatchSource::class, + 'to_user_id' => 'integer', + 'sent_at' => 'datetime', + 'reported_at' => 'datetime', + 'raw_payload' => 'array', + ]; + } + + /** + * 마스킹된 수신 전화번호를 반환합니다 (010-****-5678 형식). + * + * 가운데 4자리를 `*` 로 가린다. 뒤 4자리·앞 3자리는 노출한다. 전체보기는 + * 권한(messaging.view)이 있는 상세 화면에서 원본 to_number 로 별도 제공한다. + * + * @return string 마스킹된 번호 + */ + public function getMaskedNumberAttribute(): string + { + $digits = preg_replace('/[^0-9]/', '', $this->to_number) ?? ''; + $len = strlen($digits); + + if ($len < 7) { + // 너무 짧으면 뒤 절반만 노출 + return str_repeat('*', (int) ceil($len / 2)).substr($digits, (int) ceil($len / 2)); + } + + $head = substr($digits, 0, $len - 8 > 0 ? 3 : $len - 4); + $tail = substr($digits, -4); + + return $head.'-****-'.$tail; + } + + /** + * 회원 수신자 관계 (비회원이면 null). + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'to_user_id'); + } + + /** + * refkey 로 조회하는 스코프 (webhook 매칭). + * + * @param Builder $query + * @param string $refkey 우리 부여 키 + * @return Builder + */ + public function scopeByRefkey(Builder $query, string $refkey): Builder + { + return $query->where('refkey', $refkey); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php new file mode 100644 index 00000000..9ab0a20f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php @@ -0,0 +1,87 @@ + + */ + protected $fillable = [ + 'notification_type', + 'channel', + 'template_code', + 'template_name', + 'fallback_sms_enabled', + 'is_active', + ]; + + /** + * 속성 캐스팅 정의 + * + * @return array + */ + protected function casts(): array + { + return [ + 'channel' => DispatchChannel::class, + 'fallback_sms_enabled' => 'boolean', + 'is_active' => 'boolean', + ]; + } + + /** + * 활성 연동만 조회하는 스코프. + * + * @param Builder $query + * @return Builder + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + /** + * 알림 유형으로 조회하는 스코프 (발송 시 template_code 해석). + * + * @param Builder $query + * @param string $notificationType 코어 notification_definitions.type + * @return Builder + */ + public function scopeByNotificationType(Builder $query, string $notificationType): Builder + { + return $query->where('notification_type', $notificationType); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 4594a71f..86e75ffd 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -7,8 +7,13 @@ namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; use Illuminate\Notifications\ChannelManager; use Illuminate\Notifications\Notification; +use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; +use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; +use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService; /** * 비즈뿌리오 메시징 플러그인 서비스 프로바이더. @@ -29,18 +34,21 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * @var array */ protected array $repositories = [ - // Phase 4: BizppurioDispatchRepositoryInterface / BizppurioNotificationBindingRepositoryInterface + BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class, + BizppurioNotificationBindingRepositoryInterface::class => BizppurioNotificationBindingRepository::class, ]; /** * CacheInterface 가 필요한 서비스 (contextual binding). * - * Phase 2 에서 발송 토큰 캐시 서비스(BizppurioTokenService)를 등록한다. + * - BizppurioTokenService(Phase 2): 발송 토큰 캐시 + * - WebhookReportService(Phase 4): 잔액부족 알림 쿨다운(D3 중복 방지) * * @var array */ protected array $cacheServices = [ BizppurioTokenService::class, + WebhookReportService::class, ]; /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php new file mode 100644 index 00000000..8f52d1af --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php @@ -0,0 +1,90 @@ + $data 이력 데이터 + * @return BizppurioDispatch 생성된 이력 + */ + public function create(array $data): BizppurioDispatch + { + return BizppurioDispatch::create($data); + } + + /** + * refkey 로 발송 이력을 조회합니다. + * + * @param string $refkey 우리 부여 키 + * @return BizppurioDispatch|null 매칭된 이력 또는 null + */ + public function findByRefkey(string $refkey): ?BizppurioDispatch + { + return BizppurioDispatch::query()->byRefkey($refkey)->first(); + } + + /** + * 발송 이력의 속성을 갱신합니다. + * + * @param BizppurioDispatch $dispatch 대상 이력 + * @param array $data 갱신 데이터 + * @return BizppurioDispatch 갱신된 이력 + */ + public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch + { + $dispatch->fill($data)->save(); + + return $dispatch; + } + + /** + * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다. + * + * @param array $filters channel / status / date_from / date_to / keyword + * @param int $perPage 페이지당 건수 + * @return LengthAwarePaginator + */ + public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator + { + $query = BizppurioDispatch::query(); + + if (! empty($filters['channel'])) { + $query->where('channel', $filters['channel']); + } + + if (! empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (! empty($filters['date_from'])) { + $query->where('sent_at', '>=', $filters['date_from']); + } + + if (! empty($filters['date_to'])) { + $query->where('sent_at', '<=', $filters['date_to']); + } + + if (! empty($filters['keyword'])) { + $keyword = $filters['keyword']; + $query->where(function ($q) use ($keyword) { + $q->where('to_number', 'like', "%{$keyword}%") + ->orWhere('to_name', 'like', "%{$keyword}%") + ->orWhere('refkey', 'like', "%{$keyword}%"); + }); + } + + return $query->orderByDesc('created_at')->paginate($perPage); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php new file mode 100644 index 00000000..ad1deff0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php @@ -0,0 +1,79 @@ +active() + ->byNotificationType($notificationType) + ->where('channel', $channel) + ->first(); + } + + /** + * 채널의 모든 연동을 조회합니다. + * + * @param string $channel 채널 + * @return Collection + */ + public function allByChannel(string $channel = 'alimtalk'): Collection + { + return BizppurioNotificationBinding::query() + ->where('channel', $channel) + ->get(); + } + + /** + * 알림 유형+채널 연동을 생성하거나 갱신합니다. + * + * @param string $notificationType 코어 notification_definitions.type + * @param string $channel 채널 + * @param array $data 갱신 데이터 + * @return BizppurioNotificationBinding 저장된 연동 + */ + public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding + { + $binding = BizppurioNotificationBinding::firstOrNew([ + 'notification_type' => $notificationType, + 'channel' => $channel, + ]); + + $binding->fill($data)->save(); + + return $binding; + } + + /** + * 알림 유형+채널 연동을 삭제합니다. + * + * @param string $notificationType 코어 notification_definitions.type + * @param string $channel 채널 + * @return void + */ + public function delete(string $notificationType, string $channel = 'alimtalk'): void + { + BizppurioNotificationBinding::query() + ->byNotificationType($notificationType) + ->where('channel', $channel) + ->delete(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php new file mode 100644 index 00000000..c4ed0052 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php @@ -0,0 +1,51 @@ + $data 이력 데이터 + * @return BizppurioDispatch 생성된 이력 + */ + public function create(array $data): BizppurioDispatch; + + /** + * refkey 로 발송 이력을 조회합니다 (webhook 매칭). + * + * @param string $refkey 우리 부여 키 + * @return BizppurioDispatch|null 매칭된 이력 또는 null(위조) + */ + public function findByRefkey(string $refkey): ?BizppurioDispatch; + + /** + * 발송 이력의 속성을 갱신합니다. + * + * @param BizppurioDispatch $dispatch 대상 이력 + * @param array $data 갱신 데이터 + * @return BizppurioDispatch 갱신된 이력 + */ + public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch; + + /** + * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다 (이력 화면). + * + * @param array $filters channel / status / date_from / date_to / keyword + * @param int $perPage 페이지당 건수 + * @return LengthAwarePaginator + */ + public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php new file mode 100644 index 00000000..e48b7bf8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php @@ -0,0 +1,53 @@ + + */ + public function allByChannel(string $channel = 'alimtalk'): Collection; + + /** + * 알림 유형+채널 연동을 생성하거나 갱신합니다 (탭 편집 모달 저장). + * + * @param string $notificationType 코어 notification_definitions.type + * @param string $channel 채널 + * @param array $data template_code / template_name / fallback_sms_enabled / is_active + * @return BizppurioNotificationBinding 저장된 연동 + */ + public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding; + + /** + * 알림 유형+채널 연동을 삭제합니다 (연동 해제). + * + * @param string $notificationType 코어 notification_definitions.type + * @param string $channel 채널 + * @return void + */ + public function delete(string $notificationType, string $channel = 'alimtalk'): void; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php new file mode 100644 index 00000000..7a592863 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php @@ -0,0 +1,112 @@ +categorize($code) === ResultCategory::Success; + } + + /** + * 결과 코드가 잔액 부족인지 여부 (D3 자체 알림 트리거). + * + * @param string $code 결과 코드 + * @return bool + */ + public function isBalanceLow(string $code): bool + { + return in_array($code, self::BALANCE_LOW_CODES, true); + } + + /** + * 결과 코드의 사람이 읽는 사유(로케일)를 반환합니다. + * + * lang 에 정의된 코드면 사유를, 없으면 null 을 반환한다. + * + * @param string $code 결과 코드 + * @return string|null 사유 또는 null(미정의) + */ + public function reason(string $code): ?string + { + $key = "sirsoft-message_bizppurio::result_codes.{$code}"; + $reason = __($key); + + // __() 는 미정의 시 키 문자열을 그대로 반환 → 미정의로 판정. + return $reason === $key ? null : $reason; + } + + /** + * 표시용 라벨 `사유 (코드)` 을 반환합니다. + * + * lang 에 사유가 있으면 "사유 (코드)", 없으면 코드만 반환한다. + * + * @param string $code 결과 코드 + * @return string 표시 라벨 + */ + public function label(string $code): string + { + $reason = $this->reason($code); + + return $reason === null ? $code : "{$reason} ({$code})"; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index 4bd4bf0a..d9e3856e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Services; +use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationTemplateService; @@ -11,7 +12,10 @@ use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel; +use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource; +use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; /** * 코어 알림 시스템의 sms 채널 드라이버. @@ -40,11 +44,13 @@ class SmsChannelDriver * @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별 * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) */ public function __construct( private readonly NotificationTemplateService $templateService, private readonly SmsTypeResolver $typeResolver, private readonly MessagePayloadBuilder $payloadBuilder, + private readonly BizppurioDispatchRepositoryInterface $dispatches, ) {} /** @@ -90,7 +96,7 @@ class SmsChannelDriver return; } - // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 → Job 위임 + // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 $refkey = $this->generateRefkey(); $channel = $this->typeResolver->resolve($message); @@ -98,9 +104,39 @@ class SmsChannelDriver ? $this->payloadBuilder->buildLms($to, $message, $refkey, (string) ($rendered['subject'] ?? '')) : $this->payloadBuilder->buildSms($to, $message, $refkey); + // 5. 발송 이력 pending 생성(Phase 4) → Job 위임. Job 이 refkey 로 조회해 sent/failed 갱신. + $this->dispatches->create([ + 'refkey' => $refkey, + 'channel' => $channel->value, + 'to_number' => $to, + 'to_name' => $notifiable->name ?? null, + 'to_user_id' => $this->resolveUserId($notifiable), + 'content' => $message, + 'notification_type' => $type, + 'status' => DispatchStatus::Pending->value, + 'source' => DispatchSource::Auto->value, + 'sent_at' => now(), + ]); + SendMessageJob::dispatch($payload, $refkey); } + /** + * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다. + * + * @param object $notifiable 수신자 + * @return int|null 회원 ID 또는 null + */ + private function resolveUserId(object $notifiable): ?int + { + // User 모델만 회원 PK 로 취급. GuestNotifiable 은 DB 회원이 아니므로 null. + if ($notifiable instanceof User) { + return (int) $notifiable->getKey(); + } + + return null; + } + /** * 수신자의 전화번호를 해석합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php new file mode 100644 index 00000000..98b26299 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php @@ -0,0 +1,146 @@ + $report 검증된 리포트 (REFKEY/RESULT/MEDIA/TELRES/KAORES + raw) + */ + public function apply(array $report): void + { + $refkey = (string) ($report['REFKEY'] ?? ''); + $resultCode = (string) ($report['RESULT'] ?? ''); + + // ① REFKEY 매칭 — 없으면 위조/미매칭, 멱등 흡수 + $dispatch = $this->dispatches->findByRefkey($refkey); + if ($dispatch === null) { + return; + } + + // ② replay — 이미 리포트 반영된 이력이면 멱등 + if ($this->wasAlreadyReported($dispatch)) { + $this->logReplayDetected($refkey, $resultCode); + + return; + } + + // ③ 결과 분류 → 상태 전이 + 리포트 필드 기록 + $category = $this->resolver->categorize($resultCode); + $status = $category === ResultCategory::Success + ? DispatchStatus::Success + : DispatchStatus::Failed; + + $this->dispatches->update($dispatch, [ + 'status' => $status->value, + 'media' => $report['MEDIA'] ?? null, + 'result_code' => $resultCode, + 'result_message' => $this->resolver->reason($resultCode), + 'fallback_status' => $this->resolveFallbackStatus($report), + 'reported_at' => now(), + 'raw_payload' => $report['raw'] ?? $report, + ]); + + // ④ 잔액부족(9070/7436) → 관리자 자체 알림 (쿨다운 내 1회만, D3) + if ($category === ResultCategory::BalanceLow) { + $this->notifyBalanceLowOnce($resultCode, $dispatch->channel->value); + } + } + + /** + * 잔액부족 알림을 쿨다운 내 1회만 발송합니다 (D3 중복 방지). + * + * 잔액이 부족하면 발송건마다 9070/7436 이 쏟아지므로, 그때마다 알림을 보내면 + * 관리자에게 알림이 폭주한다. 채널별로 캐시 키를 두어, 쿨다운(기본 1시간) 동안 + * 이미 알렸으면 발송을 건너뛴다. 발송 이력의 실패 기록(③)은 이와 무관하게 매 건 + * 남으므로, 실패 내역 자체는 모두 보존된다. + * + * 쿨다운은 environment 설정(balance_low_notify_cooldown, 초)으로 조정 가능하며, + * 화면 노출 없이 설정 파일에서만 관리한다. 잔액을 충전하면 9070/7436 이 더 이상 + * 발생하지 않으므로 알림도 자연히 멈춘다. + * + * @param string $resultCode 결과 코드(9070 문자 / 7436 알림톡) + * @param string $channel 채널(sms / lms / alimtalk) + */ + private function notifyBalanceLowOnce(string $resultCode, string $channel): void + { + $cacheKey = "bizppurio:balance_low_notified:{$channel}"; + + if ($this->cache->has($cacheKey)) { + return; + } + + $cooldown = (int) $this->pluginSettings->get( + self::PLUGIN_IDENTIFIER, + 'balance_low_notify_cooldown', + self::DEFAULT_BALANCE_LOW_COOLDOWN, + ); + + $this->cache->put($cacheKey, true, $cooldown); + + HookManager::doAction( + 'sirsoft-message_bizppurio.balance.low', + $resultCode, + $channel, + ); + } + + /** + * 대체발송 결과를 해석합니다 (webhook TELRES/KAORES). + * + * @param array $report 리포트 + * @return string|null 대체발송 결과 코드 또는 null + */ + private function resolveFallbackStatus(array $report): ?string + { + $telres = $report['TELRES'] ?? null; + $kaores = $report['KAORES'] ?? null; + + return $telres !== null && $telres !== '' + ? (string) $telres + : ($kaores !== null && $kaores !== '' ? (string) $kaores : null); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 53aedba9..6e34e5b6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -1,6 +1,10 @@ withoutMiddleware([EnforceIdentityPolicy::class, RefreshTokenExpiration::class]) + ->middleware(BizppurioWebhookIpWhitelist::class) + ->name('webhook'); + Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () { // 리포트 수신 주소 조회 (관리자 설정 페이지 표시용) // diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php index c3ffd2e3..3798f8f0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -4,6 +4,8 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Installation; use App\Enums\ExtensionOwnerType; use App\Models\Menu; +use Illuminate\Support\Facades\Schema; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Plugin; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -138,4 +140,46 @@ class InstallationTest extends PluginTestCase '비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.' ); } + + /** + * Phase 4 발송 이력·연동 테이블이 마이그레이션으로 생성된다. + */ + public function test_phase4_테이블이_생성된다(): void + { + $this->assertTrue(Schema::hasTable('bizppurio_dispatches')); + $this->assertTrue(Schema::hasTable('bizppurio_notification_bindings')); + + $this->assertTrue(Schema::hasColumns('bizppurio_dispatches', [ + 'refkey', 'messagekey', 'channel', 'to_number', 'to_user_id', + 'status', 'result_code', 'reported_at', 'raw_payload', + ])); + } + + /** + * 마이그레이션 up→down→up 왕복 후에도 리포지토리 호출 1회전이 동작한다. + */ + public function test_마이그레이션_왕복_후_리포지토리가_동작한다(): void + { + $migrations = base_path('plugins/_bundled/sirsoft-message_bizppurio/database/migrations'); + + // down + $this->artisan('migrate:rollback', ['--path' => $migrations, '--realpath' => true])->run(); + $this->assertFalse(Schema::hasTable('bizppurio_dispatches')); + + // up + $this->artisan('migrate', ['--path' => $migrations, '--realpath' => true])->run(); + $this->assertTrue(Schema::hasTable('bizppurio_dispatches')); + + // 왕복 후 Repository 호출 1회전 (create → 조회) + $dispatch = BizppurioDispatch::create([ + 'refkey' => 'roundtrip', + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + $this->assertNotNull(BizppurioDispatch::query()->byRefkey('roundtrip')->first()); + $this->assertSame('roundtrip', $dispatch->refkey); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php new file mode 100644 index 00000000..00ad7efb --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php @@ -0,0 +1,249 @@ + $refkey, + 'channel' => $channel, + 'to_number' => '01011112222', + 'content' => '테스트 본문', + 'notification_type' => 'welcome', + 'status' => DispatchStatus::Sent->value, + 'source' => 'auto', + 'sent_at' => now(), + ]); + } + + /** + * 허용 IP 로 유효 리포트를 POST 하면 200 + 상태가 success 로 갱신. + * + * @scenario source=webhook,ip=allowed,result=success + * + * @effects dispatch_status_updated_to_success,reported_at_set + */ + public function test_allowed_ip_success_report_updates_status(): void + { + $dispatch = $this->seedDispatch('ref_success'); + + $response = $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, [ + 'REFKEY' => 'ref_success', + 'RESULT' => '4100', + 'MEDIA' => 'SMS', + ]); + + $response->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4100', $dispatch->result_code); + $this->assertSame('SMS', $dispatch->media); + $this->assertNotNull($dispatch->reported_at); + } + + /** + * 실패 결과코드는 status=failed 로 갱신. + * + * @scenario source=webhook,ip=allowed,result=failure + * + * @effects dispatch_status_updated_to_failed + */ + public function test_allowed_ip_failure_report_marks_failed(): void + { + $dispatch = $this->seedDispatch('ref_fail'); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_fail', 'RESULT' => '4400']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('4400', $dispatch->result_code); + } + + /** + * 차단 IP 는 403 + 이력 미변경. + * + * @scenario source=webhook,ip=blocked + * + * @effects webhook_blocked_ip_returns_403,dispatch_unchanged + */ + public function test_blocked_ip_returns_403(): void + { + $dispatch = $this->seedDispatch('ref_blocked'); + + $this->withServerVariables(['REMOTE_ADDR' => self::BLOCKED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_blocked', 'RESULT' => '4100']) + ->assertStatus(403); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Sent, $dispatch->status); + $this->assertNull($dispatch->reported_at); + } + + /** + * 미매칭 refkey(위조)는 200 흡수 + 아무 이력도 변경 안 됨. + * + * @scenario source=webhook,ip=allowed,refkey=unknown + * + * @effects webhook_unknown_refkey_absorbed_200 + */ + public function test_unknown_refkey_absorbed_with_200(): void + { + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'nope', 'RESULT' => '4100']) + ->assertStatus(200); + + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + + /** + * replay(이미 reported_at 있는 이력)는 멱등 — 상태를 다시 뒤집지 않음. + * + * @scenario source=webhook,ip=allowed,replay=true + * + * @effects webhook_replay_is_idempotent + */ + public function test_replay_report_is_idempotent(): void + { + $dispatch = $this->seedDispatch('ref_replay'); + $dispatch->update([ + 'status' => DispatchStatus::Success->value, + 'result_code' => '4100', + 'reported_at' => now()->subMinute(), + ]); + $firstReportedAt = $dispatch->fresh()->reported_at; + + // 두 번째(중복) 리포트가 실패 코드로 와도 성공 상태를 유지해야 함 + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_replay', 'RESULT' => '4400']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4100', $dispatch->result_code); + $this->assertEquals($firstReportedAt->timestamp, $dispatch->reported_at->timestamp); + } + + /** + * 잔액부족(9070 문자)은 failed + 잔액부족 자체알림 훅 1회 발화. + * + * @scenario source=webhook,ip=allowed,result=balance_low_sms + * + * @effects webhook_balance_low_marks_failed,balance_low_hook_fired + */ + public function test_balance_low_sms_marks_failed_and_fires_hook(): void + { + $dispatch = $this->seedDispatch('ref_balance', DispatchChannel::Sms->value); + + $fired = []; + HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) { + $fired[] = $args; + }); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_balance', 'RESULT' => '9070']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertCount(1, $fired, '잔액부족 훅은 1회만 발화해야 함'); + $this->assertSame(['9070', 'sms'], $fired[0]); + } + + /** + * 잔액부족 알림은 쿨다운 동안 채널별 1회만 발송된다 (D3 중복 방지). + * + * 서로 다른 발송건이 연속으로 9070(문자 잔액부족)을 받아도, 쿨다운 내에서는 + * 관리자 알림이 1회만 발화한다. 단, 발송 이력의 실패 기록은 두 건 모두 남는다. + * + * @scenario source=webhook,ip=allowed,result=balance_low_sms,repeat=true + * + * @effects balance_low_notification_deduplicated_within_cooldown,both_dispatches_marked_failed + */ + public function test_balance_low_notification_is_deduplicated_within_cooldown(): void + { + $first = $this->seedDispatch('ref_bal_1', DispatchChannel::Sms->value); + $second = $this->seedDispatch('ref_bal_2', DispatchChannel::Sms->value); + + $fired = []; + HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) { + $fired[] = $args; + }); + + // 두 발송건이 각각 9070(문자 잔액부족) 리포트를 받음 + foreach (['ref_bal_1', 'ref_bal_2'] as $refkey) { + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => $refkey, 'RESULT' => '9070']) + ->assertStatus(200); + } + + // 알림은 1회만 (쿨다운 중복 방지) + $this->assertCount(1, $fired, '쿨다운 동안 잔액부족 알림은 1회만 발화해야 함'); + + // 그러나 실패 이력은 두 건 모두 기록됨 + $this->assertSame(DispatchStatus::Failed, $first->fresh()->status); + $this->assertSame(DispatchStatus::Failed, $second->fresh()->status); + $this->assertSame('9070', $first->fresh()->result_code); + $this->assertSame('9070', $second->fresh()->result_code); + } + + /** + * webhook 은 코어 토큰/IDV 미들웨어 제외 — 비인증 실 POST 가 401 이 아닌 200. + * + * @scenario source=webhook,ip=allowed,auth=none + * + * @effects webhook_bypasses_auth_middleware_returns_200 + */ + public function test_webhook_bypasses_auth_middleware(): void + { + $this->seedDispatch('ref_noauth'); + + // Authorization 헤더 없이(비인증) POST — 미들웨어 제외로 200 이어야 함 + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_noauth', 'RESULT' => '4100']) + ->assertStatus(200); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php index 45fba633..46dbe4d9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -3,13 +3,16 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Jobs; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioApiClient; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * SendMessageJob — 성공/일시오류 재시도/영구실패 즉시 판정 검증. + * SendMessageJob — 성공/일시오류 재시도/영구실패 판정 + 이력 갱신 검증(Phase 4). */ class SendMessageJobTest extends PluginTestCase { @@ -33,13 +36,39 @@ class SendMessageJobTest extends PluginTestCase return $mock; } - public function test_성공시_예외없이_종료(): void + /** + * 컨테이너에서 실제 리포지토리(Provider 바인딩)를 해석해 반환. + */ + private function dispatches(): BizppurioDispatchRepositoryInterface { - $job = new SendMessageJob($this->payload, 'ref1'); - $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk'])); + return app(BizppurioDispatchRepositoryInterface::class); + } - // 예외 미발생 = 정상 완료 - $this->assertTrue(true); + /** + * refkey 로 pending 이력을 1건 seed. + */ + private function seedPending(string $refkey): BizppurioDispatch + { + return BizppurioDispatch::create([ + 'refkey' => $refkey, + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'hi', + 'status' => DispatchStatus::Pending->value, + 'source' => 'auto', + ]); + } + + public function test_성공시_예외없이_종료하고_이력을_sent로_갱신한다(): void + { + $dispatch = $this->seedPending('ref1'); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk']), $this->dispatches()); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Sent, $dispatch->status); + $this->assertSame('mk', $dispatch->messagekey); } public function test_일시오류_결과코드는_예외를_던져_재시도한다(): void @@ -49,20 +78,24 @@ class SendMessageJobTest extends PluginTestCase $this->expectException(BizppurioApiException::class); try { - $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp'])); + $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp']), $this->dispatches()); } catch (BizppurioApiException $e) { $this->assertSame('5003', $e->getResultCode()); throw $e; } } - public function test_영구실패_결과코드는_예외없이_종료된다(): void + public function test_영구실패_결과코드는_예외없이_종료하고_이력을_failed로_갱신한다(): void { + $dispatch = $this->seedPending('ref1'); + // 3006(계정오류) = 영구실패 → 재시도 안 함(예외 없음) $job = new SendMessageJob($this->payload, 'ref1'); - $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error'])); + $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error']), $this->dispatches()); - $this->assertTrue(true); + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('3006', $dispatch->result_code); } public function test_tries와_backoff_기본값(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php new file mode 100644 index 00000000..f0ef37f7 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php @@ -0,0 +1,90 @@ + 'r1', + 'channel' => 'lms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'pending', + 'source' => 'auto', + ]); + + $this->assertSame(DispatchChannel::Lms, $dispatch->channel); + $this->assertSame(DispatchStatus::Pending, $dispatch->status); + } + + /** + * 마스킹 접근자는 가운데 4자리를 가린다. + */ + public function test_masked_number(): void + { + $dispatch = new BizppurioDispatch(['to_number' => '01012345678']); + $this->assertSame('010-****-5678', $dispatch->masked_number); + } + + /** + * 하이픈 포함 번호도 정규화 후 마스킹한다. + */ + public function test_masked_number_with_hyphens(): void + { + $dispatch = new BizppurioDispatch(['to_number' => '010-1234-5678']); + $this->assertSame('010-****-5678', $dispatch->masked_number); + } + + /** + * to_user_id 로 회원 관계를 로드한다. + */ + public function test_user_relation(): void + { + $user = User::factory()->create(); + $dispatch = BizppurioDispatch::create([ + 'refkey' => 'r2', + 'channel' => 'sms', + 'to_number' => '01011112222', + 'to_user_id' => $user->id, + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + + $this->assertNotNull($dispatch->user); + $this->assertSame($user->id, $dispatch->user->id); + } + + /** + * byRefkey 스코프로 조회한다. + */ + public function test_by_refkey_scope(): void + { + BizppurioDispatch::create([ + 'refkey' => 'unique_ref', + 'channel' => 'sms', + 'to_number' => '01000000000', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + + $found = BizppurioDispatch::query()->byRefkey('unique_ref')->first(); + $this->assertNotNull($found); + $this->assertSame('unique_ref', $found->refkey); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php new file mode 100644 index 00000000..a9c8ea35 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php @@ -0,0 +1,75 @@ +repo = new BizppurioDispatchRepository; + } + + private function makeDispatch(array $overrides = []): BizppurioDispatch + { + return $this->repo->create(array_merge([ + 'refkey' => 'r'.uniqid(), + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + 'sent_at' => now(), + ], $overrides)); + } + + public function test_create_and_find_by_refkey(): void + { + $this->makeDispatch(['refkey' => 'known']); + $this->assertNotNull($this->repo->findByRefkey('known')); + $this->assertNull($this->repo->findByRefkey('missing')); + } + + public function test_update(): void + { + $dispatch = $this->makeDispatch(['refkey' => 'upd', 'status' => 'sent']); + $this->repo->update($dispatch, ['status' => 'success', 'result_code' => '4100']); + + $this->assertSame('success', $dispatch->fresh()->status->value); + $this->assertSame('4100', $dispatch->fresh()->result_code); + } + + public function test_paginate_filters_by_channel_and_status(): void + { + $this->makeDispatch(['channel' => 'sms', 'status' => 'success']); + $this->makeDispatch(['channel' => 'alimtalk', 'status' => 'failed']); + $this->makeDispatch(['channel' => 'sms', 'status' => 'failed']); + + $smsPage = $this->repo->paginate(['channel' => 'sms']); + $this->assertSame(2, $smsPage->total()); + + $failedPage = $this->repo->paginate(['status' => 'failed']); + $this->assertSame(2, $failedPage->total()); + } + + public function test_paginate_keyword_matches_number_or_refkey(): void + { + $this->makeDispatch(['refkey' => 'refABC', 'to_number' => '01099998888']); + $this->makeDispatch(['refkey' => 'other', 'to_number' => '01000000000']); + + $byRefkey = $this->repo->paginate(['keyword' => 'refABC']); + $this->assertSame(1, $byRefkey->total()); + + $byNumber = $this->repo->paginate(['keyword' => '9999']); + $this->assertSame(1, $byNumber->total()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php new file mode 100644 index 00000000..88c0be33 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php @@ -0,0 +1,67 @@ +repo = new BizppurioNotificationBindingRepository; + } + + public function test_upsert_creates_then_updates(): void + { + $created = $this->repo->upsert('welcome', 'alimtalk', [ + 'template_code' => 'TW_1', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => true, + ]); + $this->assertSame('TW_1', $created->template_code); + + // 같은 (type, channel) 재저장 → 갱신(중복 생성 아님) + $updated = $this->repo->upsert('welcome', 'alimtalk', [ + 'template_code' => 'TW_2', + 'template_name' => '가입환영v2', + 'fallback_sms_enabled' => false, + ]); + + $this->assertSame($created->id, $updated->id); + $this->assertSame('TW_2', $updated->template_code); + $this->assertDatabaseCount('bizppurio_notification_bindings', 1); + } + + public function test_find_active_only_returns_active(): void + { + $this->repo->upsert('welcome', 'alimtalk', [ + 'template_code' => 'TW_1', + 'template_name' => 'n', + 'is_active' => true, + ]); + + $this->assertNotNull($this->repo->findActive('welcome')); + + $this->repo->upsert('welcome', 'alimtalk', ['is_active' => false, 'template_code' => 'TW_1', 'template_name' => 'n']); + $this->assertNull($this->repo->findActive('welcome')); + } + + public function test_delete(): void + { + $this->repo->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_9', + 'template_name' => '주문완료', + ]); + + $this->repo->delete('order_confirmed', 'alimtalk'); + $this->assertNull($this->repo->findActive('order_confirmed')); + $this->assertDatabaseCount('bizppurio_notification_bindings', 0); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php new file mode 100644 index 00000000..6da7f577 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -0,0 +1,84 @@ +resolver = new ResultCodeResolver; + } + + /** + * 성공 코드(발송 1000 / SMS 4100 / LMS 6600 / 알림톡 7000 / 카카오 200)는 Success. + */ + public function test_success_codes_categorized_as_success(): void + { + foreach (['1000', '4100', '6600', '7000', '200'] as $code) { + $this->assertSame(ResultCategory::Success, $this->resolver->categorize($code), "코드 {$code}"); + $this->assertTrue($this->resolver->isSuccess($code), "코드 {$code}"); + } + } + + /** + * 잔액부족(9070 문자 / 7436 알림톡)은 BalanceLow + isBalanceLow true. + */ + public function test_balance_low_codes(): void + { + foreach (['9070', '7436'] as $code) { + $this->assertSame(ResultCategory::BalanceLow, $this->resolver->categorize($code), "코드 {$code}"); + $this->assertTrue($this->resolver->isBalanceLow($code), "코드 {$code}"); + $this->assertFalse($this->resolver->isSuccess($code), "코드 {$code}"); + } + } + + /** + * 일시오류 코드는 Retry. + */ + public function test_retryable_codes_categorized_as_retry(): void + { + foreach (['5003', '5004', '5005', '9000', '3011', '3013'] as $code) { + $this->assertSame(ResultCategory::Retry, $this->resolver->categorize($code), "코드 {$code}"); + } + } + + /** + * 위 분류에 없는 코드는 영구 실패. + */ + public function test_unknown_and_failure_codes_are_permanent_failure(): void + { + foreach (['4400', '6606', '7204', '2000', '9999'] as $code) { + $this->assertSame(ResultCategory::PermanentFailure, $this->resolver->categorize($code), "코드 {$code}"); + } + } + + /** + * reason 은 lang 정의 코드는 사유를, 미정의 코드는 null 을 반환. + */ + public function test_reason_resolves_defined_codes_and_null_for_unknown(): void + { + $this->assertSame('음영 지역', $this->resolver->reason('4400')); + $this->assertNull($this->resolver->reason('9999')); + } + + /** + * label 은 정의 코드는 "사유 (코드)", 미정의 코드는 코드만 반환. + */ + public function test_label_formats_reason_with_code(): void + { + $this->assertSame('음영 지역 (4400)', $this->resolver->label('4400')); + $this->assertSame('9999', $this->resolver->label('9999')); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index a89fcb4f..0028ede4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -10,7 +10,9 @@ use App\Services\NotificationTemplateService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver; @@ -56,6 +58,7 @@ class SmsChannelDriverTest extends PluginTestCase $templateService, new SmsTypeResolver, $builder ?? $this->spyBuilder(), + new BizppurioDispatchRepository, ); } @@ -127,6 +130,45 @@ class SmsChannelDriverTest extends PluginTestCase $this->assertSame('01099990000', $builder->calls[0]['to']); } + public function test_회원_발송_시_pending_이력을_생성하고_회원id를_기록한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']); + + $this->makeDriver($this->fakeTemplate())->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 1); + $dispatch = BizppurioDispatch::first(); + $this->assertSame('pending', $dispatch->status->value); + $this->assertSame('01012345678', $dispatch->to_number); + $this->assertSame($member->id, $dispatch->to_user_id, '회원 발송은 to_user_id 를 채워야 한다.'); + $this->assertSame('order_confirmed', $dispatch->notification_type); + } + + public function test_비회원_발송_이력은_회원id가_null이다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + + $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification($data)); + + $dispatch = BizppurioDispatch::first(); + $this->assertNotNull($dispatch); + $this->assertNull($dispatch->to_user_id, '비회원 발송은 to_user_id 가 null 이어야 한다.'); + } + + public function test_발송하지_않으면_이력도_생성하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 템플릿 없음 → skip → 이력도 없어야 함 + $this->makeDriver(null)->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + public function test_전화번호가_없으면_발송하지_않는다(): void { Bus::fake(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml new file mode 100644 index 00000000..6f22db53 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml @@ -0,0 +1,73 @@ +# audit:allow test-scenario-coverage reason: | +# 발송 결과 기록·webhook 수신·결과코드 해석의 axes cross product 는 PHPUnit +# (BizppurioWebhookTest·ResultCodeResolverTest·SmsChannelDriverTest·SendMessageJobTest· +# BizppurioDispatchTest·리포지토리·Installation)로 실제 HTTP/DB 왕복을 관찰한다. +# 발송 이력의 "표시 화면"은 이번 단계에 두지 않는다 — 코어 알림 발송 이력 화면에 비즈뿌리오 +# 결과(도달·실패사유)를 얹는 방식(A-2, 후속 Phase)으로 이관하기로 결정했기 때문이다. +# 따라서 이 매니페스트는 결과 저장·수신·해석까지만 커버하며, 표시 UI axis/effects 는 +# A-2 착수 시 그 화면(코어 이력 + 주입)에 대한 매니페스트로 추가한다. + +feature: 비즈뿌리오 발송 결과 기록 + webhook 수신 + 결과코드 (Phase 4) + +description: | + 발송 결과를 bizppurio_dispatches 에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로 상태를 + 갱신하며, 결과코드를 사유+분류로 해석한다. (표시 화면은 A-2 후속에서 코어 이력에 통합) + + 핵심 동작: + - 발송 시 SmsChannelDriver 가 pending 이력 생성(회원=to_user_id, 비회원=null) + - SendMessageJob 이 발송 응답으로 sent(+messagekey) 또는 failed(+result_code) 갱신 + - webhook: IP 화이트리스트(115.71.53.78/79/94/95, 환경 우회 없음) → refkey 매칭 → + replay 멱등(reported_at) → RESULT 분류 → 상태전이 + media/fallback/raw_payload/reported_at + - 잔액부족(9070 문자 / 7436 알림톡): failed 뒤집기 + 관리자 자체알림(쿨다운 내 1회) + - webhook 은 코어 토큰/IDV 미들웨어 제외(라우트 withoutMiddleware) — 비인증 실 POST 200 + +axes: + channel: [sms, lms, alimtalk] + result_category: [success, failure, retry, balance_low] + recipient_kind: [member, guest] + webhook_ip: [allowed, blocked] + +exclusions: + - { webhook_ip: blocked, result_category: success, reason: "차단 IP 는 결과코드와 무관하게 403 — 분류 조합 무의미" } + - { channel: alimtalk, result_category: retry, reason: "알림톡 발송 드라이버는 Phase 6 — 이번 범위는 채널 등록/이력 스키마까지" } + +effects: + # 발송 시 이력 생성 (백엔드) + - sms_send_creates_pending_dispatch_with_member_user_id + - guest_send_creates_pending_dispatch_with_null_user_id + - skip_send_creates_no_dispatch + # 발송 응답 → 이력 갱신 (백엔드) + - send_success_marks_sent_and_stores_messagekey + - send_permanent_failure_marks_failed_with_result_code + - send_retryable_throws_for_queue_retry + # webhook 수신 (백엔드) + - allowed_ip_success_report_updates_status_to_success + - allowed_ip_failure_report_marks_failed + - blocked_ip_returns_403_and_leaves_dispatch_unchanged + - unknown_refkey_absorbed_with_200 + - replay_report_is_idempotent + - balance_low_marks_failed_and_fires_admin_notification_once + - balance_low_notification_deduplicated_within_cooldown_but_all_dispatches_marked_failed + - webhook_bypasses_auth_middleware_returns_200 + # 결과코드 해석 (백엔드) + - success_codes_categorized_as_success + - balance_low_codes_9070_7436_categorized_as_balance_low + - retryable_codes_categorized_as_retry + - unknown_codes_categorized_as_permanent_failure + - label_formats_reason_with_code + # DB (마이그레이션) + - phase4_tables_created_with_expected_columns + - migration_roundtrip_up_down_up_preserves_repository_ops + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php + +audit: + status: codified From 73b05aa42f8e9dbe48d8c449d60c75f6fdc84ab4 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 14 Jul 2026 16:16:03 +0900 Subject: [PATCH 06/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=99=94=EB=A9=B4=20(=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=83=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 설정 페이지 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 등록·수정·삭제·검수· 상태변경한다. 진입은 코어 소유 설정 페이지 하나로 통일하고 getAdminMenus 는 폐기했다. 템플릿은 kapi 에 실시간 위임하며 DB 에 저장하지 않는다. 등록 폼은 계획서 §6-4 전체를 구현한다: 템플릿 코드(자동 생성/직접 입력), 카테고리 2단 드롭다운, 템플릿 유형(기본형/이미지형/강조표기형), 이미지형 파일 업로드, 본문 변수 클릭 삽입 + 알림별 변수 참고표, 선택 항목 6개 체크펼침(부가정보·미리보기·버튼·바로연결·대표링크·보안), 등록/등록 후 검수요청 2버튼. --- docs/backend/api/README.md | 4 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 7 +- .../dist/js/plugin.iife.js | 2 + .../dist/js/plugin.iife.js.map | 1 + .../docs/api/README.md | 3 +- .../docs/api/alimtalk-templates.md | 730 ++++++++++ .../lang/en/activity_log.php | 34 + .../lang/en/messages.php | 50 + .../lang/ko/activity_log.php | 34 + .../lang/ko/messages.php | 50 + .../sirsoft-message_bizppurio/package.json | 4 + .../sirsoft-message_bizppurio/plugin.json | 7 + .../sirsoft-message_bizppurio/plugin.php | 78 +- .../layouts/alimtalk_templates.test.tsx | 236 ++++ .../layouts/plugin_settings.test.tsx | 12 +- .../resources/js/handlers/index.ts | 14 + .../resources/js/handlers/insertVariable.ts | 103 ++ .../js/handlers/uploadTemplateImage.ts | 98 ++ .../resources/js/index.ts | 94 ++ .../resources/js/types.ts | 28 + .../resources/lang/en.json | 143 ++ .../resources/lang/ko.json | 143 ++ .../layouts/admin/plugin_settings.json | 1222 ++++++++++++++++- .../Admin/AlimtalkTemplateController.php | 284 ++++ .../Requests/StoreAlimtalkTemplateRequest.php | 109 ++ .../UpdateAlimtalkTemplateRequest.php | 16 + .../Requests/UploadTemplateImageRequest.php | 51 + .../src/Services/AlimtalkTemplateService.php | 581 ++++++++ .../src/Services/BizppurioKakaoApiClient.php | 42 + .../src/routes/api.php | 37 + .../AlimtalkTemplateControllerTest.php | 301 ++++ .../Feature/Installation/InstallationTest.php | 43 +- .../Services/AlimtalkTemplateServiceTest.php | 274 ++++ .../Services/BizppurioKakaoApiClientTest.php | 27 + .../tests/scenarios/alimtalk-templates.yaml | 104 ++ .../tests/scenarios/plugin-settings.yaml | 6 +- .../sirsoft-message_bizppurio/vite.config.ts | 41 + 37 files changed, 4902 insertions(+), 111 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 34b2ebfd..db6fff1f 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 374 +- **확장 수**: 10 · **엔드포인트 수**: 388 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,7 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 2 / 2 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 3 / 16 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 256ccfa2..aa2082ce 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -11,10 +11,15 @@ - 비즈뿌리오 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. - 연동 환경을 운영으로 설정하면 발송에 필요한 아이디·비밀번호·발신번호가 반드시 입력되도록 검증하고, 실제 발송·비용이 발생한다는 경고를 함께 안내합니다. - 발송 결과(리포트)를 받을 수신 주소를 설정 화면에서 확인·복사할 수 있습니다. 이 주소를 비즈뿌리오에 등록하면 발송 결과가 이력에 자동 기록됩니다. -- 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다. +- 비즈뿌리오 메시지 발송 기능에 대한 조회·관리 권한을 추가했습니다. 관련 화면은 플러그인 설정 페이지에서 진입합니다. - 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) - 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.) - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. +- 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다. +- 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.) +- 본문에 넣을 수 있는 변수를 클릭하면 커서 위치에 바로 삽입되며, 알림 종류별로 제공되는 변수 예시를 참고표로 함께 안내합니다. +- 선택 항목(부가정보·미리보기 메시지·버튼·바로연결·대표 링크·템플릿 보안)을 체크해 필요한 것만 펼쳐 입력할 수 있습니다. +- 템플릿은 [등록]으로 임시 저장하거나 [등록 후 검수요청]으로 등록과 동시에 카카오 검수를 요청할 수 있습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js new file mode 100644 index 00000000..057cb622 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js @@ -0,0 +1,2 @@ +var SirsoftMessageBizppurio=(function(f){"use strict";const w=window.G7Core?.createLogger?.("MessageBizppurio:InsertVariable")??{log:(...e)=>console.log("[MessageBizppurio:InsertVariable]",...e),warn:(...e)=>console.warn("[MessageBizppurio:InsertVariable]",...e),error:(...e)=>console.error("[MessageBizppurio:InsertVariable]",...e)},y="templateContent",A="templateForm.templateContent";function C(e,o){const t=window.G7Core,g=e.params??{},n=String(g.variable??"").trim(),a=String(g.target??y),s=String(g.statePath??A);if(n===""){w.warn("삽입할 변수명이 지정되지 않았습니다.");return}if(!t?.state?.getLocal||!t?.state?.setLocal){w.error("G7Core.state API 를 사용할 수 없습니다.");return}const d=o?.state??t.state.getLocal()??{},r=String(P(d,s)??""),i=`#{${n}}`,l=document.querySelector(`textarea[name="${a}"], input[name="${a}"]`),u=(l?.value??"")===r,b=u?l?.selectionStart??r.length:r.length,E=u?l?.selectionEnd??r.length:r.length,M=r.slice(0,b)+i+r.slice(E);if(t.state.setLocal({[s]:M}),l){const $=b+i.length;requestAnimationFrame(()=>{try{l.focus(),l.setSelectionRange($,$)}catch{}})}w.log(`변수 삽입: ${i} → ${s}`)}function P(e,o){return o.split(".").reduce((t,g)=>t?.[g],e)}const p=window.G7Core?.createLogger?.("MessageBizppurio:UploadImage")??{log:(...e)=>console.log("[MessageBizppurio:UploadImage]",...e),warn:(...e)=>console.warn("[MessageBizppurio:UploadImage]",...e),error:(...e)=>console.error("[MessageBizppurio:UploadImage]",...e)},G="/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image";async function z(e,o){const t=window.G7Core;if(!t?.state?.setLocal){p.error("G7Core.state.setLocal 을 사용할 수 없습니다.");return}const n=(o?.event??e?.event)?.target,a=n?.files?.[0];if(a){t.state.setLocal({imageUploading:!0,imageUploadError:null});try{const s=localStorage.getItem("auth_token")??"",d=new FormData;d.append("image",a,a.name);const r=await fetch(G,{method:"POST",headers:{Accept:"application/json",...s?{Authorization:`Bearer ${s}`}:{}},body:d}),i=await r.json().catch(()=>({}));if(!r.ok||i?.success===!1){const u=i?.errors?.kakao_message||i?.message||"이미지 업로드에 실패했습니다.";t.state.setLocal({imageUploading:!1,imageUploadError:u}),p.warn("이미지 업로드 실패:",u);return}const l=i?.data?.image_url??"",U=i?.data?.image_name??a.name;t.state.setLocal({imageUploading:!1,imageUploadError:null,"templateForm.templateImageUrl":l,"templateForm.templateImageName":U}),p.log("이미지 업로드 완료:",l)}catch(s){t.state.setLocal({imageUploading:!1,imageUploadError:String(s)}),p.error("이미지 업로드 예외:",s)}finally{n&&(n.value="")}}}const m={insertVariable:C,uploadTemplateImage:z},c="sirsoft-message_bizppurio",h=window.G7Core?.createLogger?.(`Plugin:${c}`)??{log:(...e)=>console.log(`[Plugin:${c}]`,...e),warn:(...e)=>console.warn(`[Plugin:${c}]`,...e),error:(...e)=>console.error(`[Plugin:${c}]`,...e)};function I(e){Object.entries(m).forEach(([o,t])=>{e.registerHandler(`${c}.${o}`,t,{category:"plugin",source:c})}),h.log(`${Object.keys(m).length} handler(s) registered:`,Object.keys(m).map(o=>`${c}.${o}`))}function S(e){const o=window.G7Core?.getActionDispatcher?.();if(o){I(o);return}if(!e){h.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let t=0;const g=50,n=()=>{const a=window.G7Core?.getActionDispatcher?.();if(a){I(a);return}++t<=g?setTimeout(n,100):h.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};n()}function L(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>S(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();S(!e)}}return L(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:c,handlers:Object.keys(m),initPlugin:L}),f.initPlugin=L,Object.defineProperty(f,Symbol.toStringTag,{value:"Module"}),f})({}); +//# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map new file mode 100644 index 00000000..63320d32 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map @@ -0,0 +1 @@ +{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/insertVariable.ts","../../resources/js/handlers/uploadTemplateImage.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * insertVariable 핸들러\n *\n * 알림톡 템플릿 등록/수정 화면에서 \"사용 가능 변수\" 버튼을 클릭하면, 대상 본문의 현재\n * 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.`)를 SSoT\n * 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌\n * (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다.\n *\n * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.\n *\n * 레이아웃 JSON 사용 예:\n * {\n * \"handler\": \"sirsoft-message_bizppurio.insertVariable\",\n * \"params\": { \"variable\": \"name\", \"target\": \"templateContent\", \"statePath\": \"templateForm.templateContent\" }\n * }\n */\n\nimport type { ActionContext, ActionWithParams } from '../types';\n\nconst logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {\n log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),\n warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),\n error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),\n};\n\n/** 삽입 대상 본문 필드(name) 기본값 */\nconst DEFAULT_TARGET = 'templateContent';\n\n/** 상태 경로 기본값 (_local 하위) */\nconst DEFAULT_STATE_PATH = 'templateForm.templateContent';\n\n/**\n * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.\n *\n * @param action 액션 객체(params.variable, params.target, params.statePath)\n * @param context 액션 컨텍스트(state 스냅샷)\n */\nexport function insertVariableHandler(\n action: ActionWithParams,\n context: ActionContext,\n): void {\n const G7Core = (window as any).G7Core;\n const params = action.params ?? {};\n const variable = String(params.variable ?? '').trim();\n const target = String(params.target ?? DEFAULT_TARGET);\n const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);\n\n if (variable === '') {\n logger.warn('삽입할 변수명이 지정되지 않았습니다.');\n return;\n }\n\n if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) {\n logger.error('G7Core.state API 를 사용할 수 없습니다.');\n return;\n }\n\n // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal.\n const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {};\n const current = String(readPath(localState, statePath) ?? '');\n\n const token = `#{${variable}}`;\n\n // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.\n const field = document.querySelector(\n `textarea[name=\"${target}\"], input[name=\"${target}\"]`,\n );\n const domValue = field?.value ?? '';\n // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.\n const inSync = domValue === current;\n const start = inSync ? (field?.selectionStart ?? current.length) : current.length;\n const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;\n\n const next = current.slice(0, start) + token + current.slice(end);\n\n G7Core.state.setLocal({ [statePath]: next });\n\n // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).\n if (field) {\n const caret = start + token.length;\n requestAnimationFrame(() => {\n try {\n field.focus();\n field.setSelectionRange(caret, caret);\n } catch {\n // setSelectionRange 미지원 input — 무시\n }\n });\n }\n\n logger.log(`변수 삽입: ${token} → ${statePath}`);\n}\n\n/**\n * dot notation 경로로 객체에서 값을 읽습니다. (예: \"templateForm.templateContent\")\n *\n * @param obj 대상 객체\n * @param path dot 경로\n * @return 경로 값(없으면 undefined)\n */\nfunction readPath(obj: Record, path: string): unknown {\n return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);\n}\n","/**\n * uploadTemplateImage 핸들러\n *\n * 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의\n * 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을\n * `_local.templateForm.templateImageUrl` 에, 파일명을 `templateImageName` 에 설정한다.\n *\n * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다.\n * 진행 상태는 `_local.imageUploading`, 오류는 `_local.imageUploadError` 로 노출한다.\n *\n * 레이아웃 JSON 사용 예:\n * {\n * \"type\": \"change\",\n * \"handler\": \"sirsoft-message_bizppurio.uploadTemplateImage\"\n * }\n * (대상 file input 의 change 이벤트에 바인딩; $event.target.files[0] 를 사용)\n */\n\nimport type { ActionContext, ActionWithParams } from '../types';\n\nconst logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? {\n log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args),\n warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args),\n error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args),\n};\n\nconst UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image';\n\n/**\n * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다.\n *\n * @param action 액션 객체(미사용 params)\n * @param context 액션 컨텍스트($event 로 파일 접근)\n */\nexport async function uploadTemplateImageHandler(\n action: ActionWithParams,\n context: ActionContext,\n): Promise {\n const G7Core = (window as any).G7Core;\n if (!G7Core?.state?.setLocal) {\n logger.error('G7Core.state.setLocal 을 사용할 수 없습니다.');\n return;\n }\n\n const event = (context?.event ?? (action as any)?.event) as Event | undefined;\n const input = event?.target as HTMLInputElement | undefined;\n const file = input?.files?.[0];\n\n if (!file) {\n return;\n }\n\n G7Core.state.setLocal({ imageUploading: true, imageUploadError: null });\n\n try {\n const token = localStorage.getItem('auth_token') ?? '';\n const form = new FormData();\n form.append('image', file, file.name);\n\n const res = await fetch(UPLOAD_URL, {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n ...(token ? { Authorization: `Bearer ${token}` } : {}),\n },\n body: form,\n });\n\n const json = await res.json().catch(() => ({}));\n\n if (!res.ok || json?.success === false) {\n const message = json?.errors?.kakao_message || json?.message || '이미지 업로드에 실패했습니다.';\n G7Core.state.setLocal({ imageUploading: false, imageUploadError: message });\n logger.warn('이미지 업로드 실패:', message);\n return;\n }\n\n const url = json?.data?.image_url ?? '';\n const name = json?.data?.image_name ?? file.name;\n\n G7Core.state.setLocal({\n imageUploading: false,\n imageUploadError: null,\n 'templateForm.templateImageUrl': url,\n 'templateForm.templateImageName': name,\n });\n\n logger.log('이미지 업로드 완료:', url);\n } catch (e) {\n G7Core.state.setLocal({ imageUploading: false, imageUploadError: String(e) });\n logger.error('이미지 업로드 예외:', e);\n } finally {\n // 같은 파일 재선택이 가능하도록 input 값 초기화\n if (input) {\n input.value = '';\n }\n }\n}\n","/**\n * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.\n *\n * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인\n * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable\n */\n\nimport { insertVariableHandler } from './insertVariable';\nimport { uploadTemplateImageHandler } from './uploadTemplateImage';\n\nexport const handlerMap = {\n insertVariable: insertVariableHandler,\n uploadTemplateImage: uploadTemplateImageHandler,\n} as const;\n","/**\n * sirsoft-message_bizppurio 플러그인 엔트리포인트.\n *\n * 플러그인 활성화 시 자동 로드되어 커스텀 핸들러(insertVariable)를 ActionDispatcher 에\n * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.\n */\n\nimport { handlerMap } from './handlers';\n\nconst PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';\n\nconst logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {\n log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n};\n\n/**\n * handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다.\n *\n * @param dispatcher 코어 ActionDispatcher 인스턴스\n */\nfunction register(dispatcher: any): void {\n Object.entries(handlerMap).forEach(([name, handler]) => {\n dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, {\n category: 'plugin',\n source: PLUGIN_IDENTIFIER,\n });\n });\n\n logger.log(\n `${Object.keys(handlerMap).length} handler(s) registered:`,\n Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`),\n );\n}\n\n/**\n * ActionDispatcher 준비 후 핸들러를 등록합니다.\n *\n * 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다.\n *\n * @param retry ActionDispatcher 부재 시 재시도 여부\n */\nfunction registerHandlers(retry: boolean): void {\n const dispatcher = (window as any).G7Core?.getActionDispatcher?.();\n\n if (dispatcher) {\n register(dispatcher);\n return;\n }\n\n if (!retry) {\n logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.');\n return;\n }\n\n let count = 0;\n const max = 50; // 최대 5초 (50 * 100ms)\n const tick = () => {\n const found = (window as any).G7Core?.getActionDispatcher?.();\n if (found) {\n register(found);\n return;\n }\n if (++count <= max) {\n setTimeout(tick, 100);\n } else {\n logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.');\n }\n };\n tick();\n}\n\n/**\n * 플러그인 초기화 — DOM 준비 후 핸들러 등록.\n */\nexport function initPlugin(): void {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => registerHandlers(true));\n } else {\n const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();\n registerHandlers(!hasDispatcher);\n }\n}\n\ninitPlugin();\n\nif (typeof window !== 'undefined') {\n (window as any).__SirsoftMessageBizppurio = {\n identifier: PLUGIN_IDENTIFIER,\n handlers: Object.keys(handlerMap),\n initPlugin,\n };\n}"],"names":["logger","args","DEFAULT_TARGET","DEFAULT_STATE_PATH","insertVariableHandler","action","context","G7Core","params","variable","target","statePath","localState","current","readPath","token","field","inSync","start","end","next","caret","obj","path","acc","key","UPLOAD_URL","uploadTemplateImageHandler","input","file","form","res","json","message","url","name","e","handlerMap","PLUGIN_IDENTIFIER","register","dispatcher","handler","registerHandlers","retry","count","max","tick","found","initPlugin","hasDispatcher"],"mappings":"sDAmBA,MAAMA,EAAW,OAAe,QAAQ,eAAe,iCAAiC,GAAM,CAC1F,IAAK,IAAIC,IAAoB,QAAQ,IAAI,oCAAqC,GAAGA,CAAI,EACrF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,oCAAqC,GAAGA,CAAI,EACvF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,oCAAqC,GAAGA,CAAI,CAC7F,EAGMC,EAAiB,kBAGjBC,EAAqB,+BAQpB,SAASC,EACZC,EACAC,EACI,CACJ,MAAMC,EAAU,OAAe,OACzBC,EAASH,EAAO,QAAU,CAAA,EAC1BI,EAAW,OAAOD,EAAO,UAAY,EAAE,EAAE,KAAA,EACzCE,EAAS,OAAOF,EAAO,QAAUN,CAAc,EAC/CS,EAAY,OAAOH,EAAO,WAAaL,CAAkB,EAE/D,GAAIM,IAAa,GAAI,CACjBT,EAAO,KAAK,sBAAsB,EAClC,MACJ,CAEA,GAAI,CAACO,GAAQ,OAAO,UAAY,CAACA,GAAQ,OAAO,SAAU,CACtDP,EAAO,MAAM,gCAAgC,EAC7C,MACJ,CAGA,MAAMY,EAAcN,GAAiB,OAASC,EAAO,MAAM,SAAA,GAAc,CAAA,EACnEM,EAAU,OAAOC,EAASF,EAAYD,CAAS,GAAK,EAAE,EAEtDI,EAAQ,KAAKN,CAAQ,IAGrBO,EAAQ,SAAS,cACnB,kBAAkBN,CAAM,mBAAmBA,CAAM,IAAA,EAI/CO,GAFWD,GAAO,OAAS,MAELH,EACtBK,EAAQD,EAAUD,GAAO,gBAAkBH,EAAQ,OAAUA,EAAQ,OACrEM,EAAMF,EAAUD,GAAO,cAAgBH,EAAQ,OAAUA,EAAQ,OAEjEO,EAAOP,EAAQ,MAAM,EAAGK,CAAK,EAAIH,EAAQF,EAAQ,MAAMM,CAAG,EAKhE,GAHAZ,EAAO,MAAM,SAAS,CAAE,CAACI,CAAS,EAAGS,EAAM,EAGvCJ,EAAO,CACP,MAAMK,EAAQH,EAAQH,EAAM,OAC5B,sBAAsB,IAAM,CACxB,GAAI,CACAC,EAAM,MAAA,EACNA,EAAM,kBAAkBK,EAAOA,CAAK,CACxC,MAAQ,CAER,CACJ,CAAC,CACL,CAEArB,EAAO,IAAI,UAAUe,CAAK,MAAMJ,CAAS,EAAE,CAC/C,CASA,SAASG,EAASQ,EAA0BC,EAAuB,CAC/D,OAAOA,EAAK,MAAM,GAAG,EAAE,OAAY,CAACC,EAAKC,IAAmCD,IAAIC,CAAG,EAAIH,CAAG,CAC9F,CClFA,MAAMtB,EAAW,OAAe,QAAQ,eAAe,8BAA8B,GAAM,CACvF,IAAK,IAAIC,IAAoB,QAAQ,IAAI,iCAAkC,GAAGA,CAAI,EAClF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,iCAAkC,GAAGA,CAAI,EACpF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,iCAAkC,GAAGA,CAAI,CAC1F,EAEMyB,EAAa,wEAQnB,eAAsBC,EAClBtB,EACAC,EACa,CACb,MAAMC,EAAU,OAAe,OAC/B,GAAI,CAACA,GAAQ,OAAO,SAAU,CAC1BP,EAAO,MAAM,qCAAqC,EAClD,MACJ,CAGA,MAAM4B,GADStB,GAAS,OAAUD,GAAgB,QAC7B,OACfwB,EAAOD,GAAO,QAAQ,CAAC,EAE7B,GAAKC,EAIL,CAAAtB,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAM,iBAAkB,KAAM,EAEtE,GAAI,CACA,MAAMQ,EAAQ,aAAa,QAAQ,YAAY,GAAK,GAC9Ce,EAAO,IAAI,SACjBA,EAAK,OAAO,QAASD,EAAMA,EAAK,IAAI,EAEpC,MAAME,EAAM,MAAM,MAAML,EAAY,CAChC,OAAQ,OACR,QAAS,CACL,OAAQ,mBACR,GAAIX,EAAQ,CAAE,cAAe,UAAUA,CAAK,IAAO,CAAA,CAAC,EAExD,KAAMe,CAAA,CACT,EAEKE,EAAO,MAAMD,EAAI,KAAA,EAAO,MAAM,KAAO,CAAA,EAAG,EAE9C,GAAI,CAACA,EAAI,IAAMC,GAAM,UAAY,GAAO,CACpC,MAAMC,EAAUD,GAAM,QAAQ,eAAiBA,GAAM,SAAW,mBAChEzB,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAO,iBAAkB0B,EAAS,EAC1EjC,EAAO,KAAK,cAAeiC,CAAO,EAClC,MACJ,CAEA,MAAMC,EAAMF,GAAM,MAAM,WAAa,GAC/BG,EAAOH,GAAM,MAAM,YAAcH,EAAK,KAE5CtB,EAAO,MAAM,SAAS,CAClB,eAAgB,GAChB,iBAAkB,KAClB,gCAAiC2B,EACjC,iCAAkCC,CAAA,CACrC,EAEDnC,EAAO,IAAI,cAAekC,CAAG,CACjC,OAASE,EAAG,CACR7B,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAO,iBAAkB,OAAO6B,CAAC,EAAG,EAC5EpC,EAAO,MAAM,cAAeoC,CAAC,CACjC,QAAA,CAEQR,IACAA,EAAM,MAAQ,GAEtB,EACJ,CCvFO,MAAMS,EAAa,CACtB,eAAgBjC,EAChB,oBAAqBuB,CACzB,ECJMW,EAAoB,4BAEpBtC,EAAW,OAAe,QAAQ,eAAe,UAAUsC,CAAiB,EAAE,GAAM,CACtF,IAAK,IAAIrC,IAAoB,QAAQ,IAAI,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,EACjF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,EACnF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,CACzF,EAOA,SAASsC,EAASC,EAAuB,CACrC,OAAO,QAAQH,CAAU,EAAE,QAAQ,CAAC,CAACF,EAAMM,CAAO,IAAM,CACpDD,EAAW,gBAAgB,GAAGF,CAAiB,IAAIH,CAAI,GAAIM,EAAS,CAChE,SAAU,SACV,OAAQH,CAAA,CACX,CACL,CAAC,EAEDtC,EAAO,IACH,GAAG,OAAO,KAAKqC,CAAU,EAAE,MAAM,0BACjC,OAAO,KAAKA,CAAU,EAAE,OAAY,GAAGC,CAAiB,IAAIH,CAAI,EAAE,CAAA,CAE1E,CASA,SAASO,EAAiBC,EAAsB,CAC5C,MAAMH,EAAc,OAAe,QAAQ,sBAAA,EAE3C,GAAIA,EAAY,CACZD,EAASC,CAAU,EACnB,MACJ,CAEA,GAAI,CAACG,EAAO,CACR3C,EAAO,KAAK,2CAA2C,EACvD,MACJ,CAEA,IAAI4C,EAAQ,EACZ,MAAMC,EAAM,GACNC,EAAO,IAAM,CACf,MAAMC,EAAS,OAAe,QAAQ,sBAAA,EACtC,GAAIA,EAAO,CACPR,EAASQ,CAAK,EACd,MACJ,CACI,EAAEH,GAASC,EACX,WAAWC,EAAM,GAAG,EAEpB9C,EAAO,MAAM,0CAA0C,CAE/D,EACA8C,EAAA,CACJ,CAKO,SAASE,GAAmB,CAC/B,GAAI,SAAS,aAAe,UACxB,SAAS,iBAAiB,mBAAoB,IAAMN,EAAiB,EAAI,CAAC,MACvE,CACH,MAAMO,EAAgB,CAAC,CAAE,OAAe,QAAQ,sBAAA,EAChDP,EAAiB,CAACO,CAAa,CACnC,CACJ,CAEA,OAAAD,EAAA,EAEI,OAAO,OAAW,MACjB,OAAe,0BAA4B,CACxC,WAAYV,EACZ,SAAU,OAAO,KAAKD,CAAU,EAChC,WAAAW,CAAA"} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index d223bcc9..3b65cd51 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,10 +4,11 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 2 · **엔드포인트 수**: 2 +- **문서 수**: 3 · **엔드포인트 수**: 16 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | +| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 | | [report.md](report.md) | `report` | 1 | | [webhook.md](webhook.md) | `webhook` | 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md new file mode 100644 index 00000000..d79b3de5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -0,0 +1,730 @@ +# Alimtalk Templates API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Alimtalk Templates 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + +## 개요 + +설정 페이지 알림톡 템플릿 탭에서 사용하는 관리 API 입니다. 카카오 관리 API(`kapi.ppurio.com`)에 +실시간 위임하며 템플릿을 DB 에 저장하지 않습니다. 조회 계열은 `messaging.view`, 변경 계열(등록· +수정·삭제·검수·상태변경·이미지 업로드)은 `messaging.manage` 권한을 요구합니다. kapi 실패(반려·차단· +상태오류)는 `422` 로 전파되며 `errors.result_code`(kapi 결과코드)·`errors.kakao_message`(카카오 사유)를 +함께 내려줍니다. + +**주요 요청 파라미터(등록·수정 `POST /` · `PUT /{code}`)** + +| 파라미터 | 타입 | 필수 | 설명 | +| --- | --- | :---: | --- | +| `templateName` | string | ✓ | 템플릿명(관리용, 최대 200자) | +| `templateContent` | string | ✓ | 본문(최대 1300자, `#{변수}` 사용) | +| `categoryCode` | string | ✓ | 카테고리 코드(카테고리 조회에서 획득) | +| `templateEmphasizeType` | string | ✓ | 유형: `NONE`/`IMAGE`/`TEXT`(1차 지원, ITEM_LIST 제외) | +| `templateCode` | string | | 템플릿 코드. 비우면 카카오가 **자동 생성** | +| `templateTitle`/`templateSubtitle` | string | △ | 강조표기형(TEXT) 필수 | +| `templateImageUrl`/`templateImageName` | string | △ | 이미지형(IMAGE) 필수 — 이미지 업로드 API 로 획득한 URL | +| `templateExtra`·`templatePreviewMessage`·`securityFlag` | mixed | | 선택: 부가정보·미리보기 메시지·보안 | +| `buttons[]`(최대5)·`quickReplies[]`(최대10)·`templateRepresentLink` | array | | 선택: 버튼·바로연결·대표 링크 | +| `requestInspection` | boolean | | true 면 등록 직후 검수요청까지 수행([등록 후 검수요청]) | + +> `senderKey`·`templateMessageType`(BA/EX/AD/MI)은 클라이언트가 보내지 않습니다. `senderKey` 는 +> 환경설정(발신프로필 키), `templateMessageType` 은 부가정보·채널추가(AC) 버튼 유무로 서버가 자동 계산합니다. + +**이미지 업로드(`POST /image`, multipart)**: 이미지형 템플릿용 이미지를 카카오 서버에 올려 URL 을 +반환합니다(`data.image_url`). 제한: jpg/png, 최대 500KB, 가로 500px 이상. + +상태별 가능 액션(정본): REG→수정/삭제/검수요청 · REQ→검수취소 · REJ→수정/삭제 · +RDY·ACT→중지/승인취소 · STP→중지해제 · DMT→휴면해제 · BLK→(없음). + +아래 엔드포인트별 상세는 `php artisan api:docgen` 이 생성한 스캐폴딩입니다. 실측이 인증을 요구해 +제외된 응답 필드/예시는 실연동 검증 단계에서 사람이 보강합니다. + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.store` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateName | body | string | 예 | max 200 | | +| templateContent | body | string | 예 | max 1300 | | +| categoryCode | body | string | 예 | max 20 | | +| templateEmphasizeType | body | string | 예 | — | | +| templateCode | body | string | 아니오 | max 30 | | +| templateTitle | body | string | 아니오 | max 23 | | +| templateSubtitle | body | string | 아니오 | max 18 | | +| templateImageName | body | string | 아니오 | max 255 | | +| templateImageUrl | body | string | 아니오 | max 500 | | +| templatePreviewMessage | body | string | 아니오 | max 40 | | +| templateExtra | body | string | 아니오 | max 500 | | +| templateHeader | body | string | 아니오 | max 16 | | +| securityFlag | body | boolean | 아니오 | — | | +| requestInspection | body | boolean | 아니오 | — | | +| buttons | body | array | 아니오 | max 5 | | +| quickReplies | body | array | 아니오 | max 10 | | +| templateRepresentLink | body | array | 아니오 | — | | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "templateName": "예시 이름", + "templateContent": "예시 내용입니다.", + "categoryCode": "예시값", + "templateEmphasizeType": "예시값", + "templateCode": "예시값", + "templateTitle": "예시 제목", + "templateSubtitle": "예시 제목", + "templateImageName": "예시 이름", + "templateImageUrl": "https://example.com", + "templatePreviewMessage": "예시값", + "templateExtra": "예시값", + "templateHeader": "예시값", + "securityFlag": true, + "requestInspection": true, + "buttons": [ + "예시값" + ], + "quickReplies": [ + "예시값" + ], + "templateRepresentLink": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@categories` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.image` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@uploadImage` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| image | body | file | 예 | max 500 | | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: multipart/form-data; boundary=----G7ExampleBoundary + +------G7ExampleBoundary +Content-Disposition: form-data; name="image"; filename="example.pdf" +Content-Type: application/octet-stream + +(바이너리 파일 내용) +------G7ExampleBoundary-- +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@profiles` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + + +### DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.destroy` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@destroy` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@show` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.update` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@update` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | +| templateName | body | string | 예 | max 200 | | +| templateContent | body | string | 예 | max 1300 | | +| categoryCode | body | string | 예 | max 20 | | +| templateEmphasizeType | body | string | 예 | — | | +| templateTitle | body | string | 아니오 | max 23 | | +| templateSubtitle | body | string | 아니오 | max 18 | | +| templateImageName | body | string | 아니오 | max 255 | | +| templateImageUrl | body | string | 아니오 | max 500 | | +| templatePreviewMessage | body | string | 아니오 | max 40 | | +| templateExtra | body | string | 아니오 | max 500 | | +| templateHeader | body | string | 아니오 | max 16 | | +| securityFlag | body | boolean | 아니오 | — | | +| requestInspection | body | boolean | 아니오 | — | | +| buttons | body | array | 아니오 | max 5 | | +| quickReplies | body | array | 아니오 | max 10 | | +| templateRepresentLink | body | array | 아니오 | — | | + +**요청 예시** + +```http +PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "templateName": "예시 이름", + "templateContent": "예시 내용입니다.", + "categoryCode": "예시값", + "templateEmphasizeType": "예시값", + "templateCode": "예시값", + "templateTitle": "예시 제목", + "templateSubtitle": "예시 제목", + "templateImageName": "예시 이름", + "templateImageUrl": "https://example.com", + "templatePreviewMessage": "예시값", + "templateExtra": "예시값", + "templateHeader": "예시값", + "securityFlag": true, + "requestInspection": true, + "buttons": [ + "예시값" + ], + "quickReplies": [ + "예시값" + ], + "templateRepresentLink": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-approval` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelApproval` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-request` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelRequest` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.release` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@release` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.request` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@requestInspection` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.reuse` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@reuse` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.stop` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@stop` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| templateCode | path | string | 예 | — | 대상 template code의 식별자 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | + + + +**설명** + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php new file mode 100644 index 00000000..606ec5c4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php @@ -0,0 +1,34 @@ + [ + 'sirsoft-message_bizppurio' => [ + 'alimtalk_template' => [ + 'create' => 'Register alimtalk template', + 'update' => 'Update alimtalk template', + 'delete' => 'Delete alimtalk template', + 'request' => 'Request alimtalk template inspection', + 'cancel_request' => 'Cancel alimtalk template inspection request', + 'stop' => 'Stop alimtalk template', + 'reuse' => 'Resume alimtalk template', + 'cancel_approval' => 'Cancel alimtalk template approval', + 'release' => 'Release alimtalk template from dormancy', + ], + ], + ], + + // Description bodies — standard pattern activity_log.description.X (resolved by DescriptionResolver). + 'description' => [ + 'alimtalk_template_create' => 'Registered alimtalk template (:template_name)', + 'alimtalk_template_update' => 'Updated alimtalk template (:template_code)', + 'alimtalk_template_delete' => 'Deleted alimtalk template (:template_code)', + 'alimtalk_template_request' => 'Requested alimtalk template inspection (:template_code)', + 'alimtalk_template_cancel_request' => 'Canceled alimtalk template inspection request (:template_code)', + 'alimtalk_template_stop' => 'Stopped alimtalk template (:template_code)', + 'alimtalk_template_reuse' => 'Resumed alimtalk template (:template_code)', + 'alimtalk_template_cancel_approval' => 'Canceled alimtalk template approval (:template_code)', + 'alimtalk_template_release' => 'Released alimtalk template from dormancy (:template_code)', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index b59faed7..ee4a74aa 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -73,5 +73,55 @@ return [ 'invalid_response' => 'Unable to parse the Bizppurio response.', 'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.', 'kakao_request_failed' => 'The Kakao management API request failed.', + 'sender_key_missing' => 'Please set the alimtalk sender profile key first.', + ], + + // Alimtalk template management (Phase 5) + 'template' => [ + // Action result messages + 'created' => 'The alimtalk template has been registered.', + 'created_requested' => 'The alimtalk template has been registered and inspection requested.', + 'updated' => 'The alimtalk template has been updated.', + 'deleted' => 'The alimtalk template has been deleted.', + 'requested' => 'Inspection has been requested.', + 'request_canceled' => 'The inspection request has been canceled.', + 'stopped' => 'The template has been stopped.', + 'resumed' => 'The template has been resumed.', + 'approval_canceled' => 'The approval has been canceled.', + 'released' => 'The template has been released from dormancy.', + + // Status badges (serviceStatus) + 'status' => [ + 'sendable' => 'Sendable', + 'inspecting' => 'Inspecting', + 'rejected' => 'Rejected', + 'uninspected' => 'Not inspected', + 'stopped' => 'Stopped', + 'blocked' => 'Blocked', + 'dormant' => 'Dormant', + 'unknown' => 'Unknown', + ], + + // Template types (templateEmphasizeType) + 'emphasize_type' => [ + 'none' => 'Basic', + 'text' => 'Highlighted', + 'image' => 'Image', + 'item_list' => 'Item list', + ], + + // Validation attribute labels (FormRequest attributes) + 'fields' => [ + 'name' => 'Template name', + 'content' => 'Template content', + 'category' => 'Category', + 'emphasize_type' => 'Template type', + 'code' => 'Template code', + 'title' => 'Highlight title', + 'subtitle' => 'Highlight subtitle', + 'image' => 'Image', + 'image_name' => 'Image file name', + 'image_url' => 'Image URL', + ], ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php new file mode 100644 index 00000000..cf10f0ae --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php @@ -0,0 +1,34 @@ + [ + 'sirsoft-message_bizppurio' => [ + 'alimtalk_template' => [ + 'create' => '알림톡 템플릿 등록', + 'update' => '알림톡 템플릿 수정', + 'delete' => '알림톡 템플릿 삭제', + 'request' => '알림톡 템플릿 검수 요청', + 'cancel_request' => '알림톡 템플릿 검수 요청 취소', + 'stop' => '알림톡 템플릿 중지', + 'reuse' => '알림톡 템플릿 중지 해제', + 'cancel_approval' => '알림톡 템플릿 승인 취소', + 'release' => '알림톡 템플릿 휴면 해제', + ], + ], + ], + + // description 본문 — 표준 패턴 activity_log.description.X (DescriptionResolver 해석 대상). + 'description' => [ + 'alimtalk_template_create' => '알림톡 템플릿 등록 (:template_name)', + 'alimtalk_template_update' => '알림톡 템플릿 수정 (:template_code)', + 'alimtalk_template_delete' => '알림톡 템플릿 삭제 (:template_code)', + 'alimtalk_template_request' => '알림톡 템플릿 검수 요청 (:template_code)', + 'alimtalk_template_cancel_request' => '알림톡 템플릿 검수 요청 취소 (:template_code)', + 'alimtalk_template_stop' => '알림톡 템플릿 중지 (:template_code)', + 'alimtalk_template_reuse' => '알림톡 템플릿 중지 해제 (:template_code)', + 'alimtalk_template_cancel_approval' => '알림톡 템플릿 승인 취소 (:template_code)', + 'alimtalk_template_release' => '알림톡 템플릿 휴면 해제 (:template_code)', + ], +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index dfe38c01..aa2079a6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -73,5 +73,55 @@ return [ 'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.', 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.', 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.', + 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', + ], + + // 알림톡 템플릿 관리 (Phase 5) + 'template' => [ + // 액션 결과 메시지 + 'created' => '알림톡 템플릿을 등록했습니다.', + 'created_requested' => '알림톡 템플릿을 등록하고 검수를 요청했습니다.', + 'updated' => '알림톡 템플릿을 수정했습니다.', + 'deleted' => '알림톡 템플릿을 삭제했습니다.', + 'requested' => '검수를 요청했습니다.', + 'request_canceled' => '검수 요청을 취소했습니다.', + 'stopped' => '템플릿을 중지했습니다.', + 'resumed' => '템플릿 중지를 해제했습니다.', + 'approval_canceled' => '승인을 취소했습니다.', + 'released' => '휴면을 해제했습니다.', + + // 상태 배지 (serviceStatus) + 'status' => [ + 'sendable' => '발송가능', + 'inspecting' => '검수중', + 'rejected' => '반려', + 'uninspected' => '미검수', + 'stopped' => '중지', + 'blocked' => '차단', + 'dormant' => '휴면', + 'unknown' => '알 수 없음', + ], + + // 템플릿 유형 (templateEmphasizeType) + 'emphasize_type' => [ + 'none' => '기본형', + 'text' => '강조표기형', + 'image' => '이미지형', + 'item_list' => '아이템리스트형', + ], + + // 검증 속성 라벨 (FormRequest attributes) + 'fields' => [ + 'name' => '템플릿명', + 'content' => '템플릿 내용', + 'category' => '카테고리', + 'emphasize_type' => '템플릿 유형', + 'code' => '템플릿 코드', + 'title' => '강조표기 문구', + 'subtitle' => '보조 문구', + 'image' => '이미지', + 'image_name' => '이미지 파일명', + 'image_url' => '이미지 링크', + ], ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json index cd1c67c9..25c55585 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/package.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", "test": "vitest", "test:run": "vitest run" }, @@ -11,6 +14,7 @@ "@types/node": "^22.0.0", "jsdom": "^25.0.0", "typescript": "^5.6.0", + "vite": "^5.4.14", "vitest": "^2.1.0" } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json index e9f35053..b4513cb6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json @@ -18,6 +18,13 @@ }, "github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio", "github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio/blob/main/CHANGELOG.md", + "assets": { + "js": { + "entry": "resources/js/index.ts", + "output": "dist/js/plugin.iife.js" + }, + "handlers": true + }, "loading": { "strategy": "global", "priority": 100 diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 3979d8a1..762108a1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -34,77 +34,13 @@ class Plugin extends AbstractPlugin } /** - * 관리자 메뉴 정의 + * 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리. * - * 코어 PluginManager 는 모듈과 달리 plugin 의 getAdminMenus() 를 자동 호출하지 않으므로 - * 본 클래스의 activate()/deactivate()/uninstall() lifecycle hook 에서 직접 sync 한다. - * 멱등성: helper.syncMenu 가 upsert 패턴이라 재활성화 시에도 row 중복 없이 그대로 복원. - * - * 최상위 「메시지 발송 관리」 아래에 알림톡 템플릿 관리·발송 이력 하위 메뉴를 둔다. - * (하위 페이지는 Phase 4·5 에서 라우트/레이아웃과 함께 실물화) - * - * @return array> - */ - public function getAdminMenus(): array - { - return [ - [ - 'name' => ['ko' => '메시지 발송 관리', 'en' => 'Messaging'], - 'slug' => 'sirsoft-message_bizppurio', - 'url' => null, - 'icon' => 'fas fa-paper-plane', - 'order' => 55, - 'children' => [ - [ - 'name' => ['ko' => '알림톡 템플릿 관리', 'en' => 'Alimtalk Templates'], - 'slug' => 'sirsoft-message_bizppurio-alimtalk-templates', - 'url' => '/admin/plugins/sirsoft-message_bizppurio/alimtalk-templates', - 'icon' => 'fas fa-comment-dots', - 'order' => 1, - 'permission' => 'sirsoft-message_bizppurio.messaging.view', - ], - [ - 'name' => ['ko' => '발송 이력', 'en' => 'Dispatch History'], - 'slug' => 'sirsoft-message_bizppurio-dispatches', - 'url' => '/admin/plugins/sirsoft-message_bizppurio/dispatches', - 'icon' => 'fas fa-clock-rotate-left', - 'order' => 2, - 'permission' => 'sirsoft-message_bizppurio.messaging.view', - ], - ], - ], - ]; - } - - /** - * 플러그인 활성화 — 관리자 메뉴 자동 등록. - * - * 모듈은 ModuleManager 가 getAdminMenus() 를 자동 처리하지만 플러그인은 - * PluginManager 가 그 책임을 위임하므로 본 메서드에서 helper 를 직접 호출한다. - * syncMenu 가 신규 메뉴 생성 시 admin 역할을 자동 부여하므로 별도 역할 처리는 불필요. - * - * @return bool 활성화 성공 여부 - */ - public function activate(): bool - { - $helper = app(ExtensionMenuSyncHelper::class); - - foreach ($this->getAdminMenus() as $menuData) { - $helper->syncMenuRecursive( - $menuData, - ExtensionOwnerType::Plugin, - $this->getIdentifier(), - ); - } - - return true; - } - - /** - * 플러그인 비활성화 — 관리자 메뉴 일괄 제거. - * - * cleanupStaleMenus 에 currentSlugs=[] 를 넘겨 본 플러그인 소속 메뉴 전체를 삭제한다. - * 자식 메뉴 + role_menus 피벗 정리는 helper 가 cascade 처리. + * 이 플러그인은 관리자 메뉴를 만들지 않는다(화면 배치 결정 2026-07-14 — 진입은 코어 + * 소유 설정 페이지 하나로 통일). 다만 이전 버전(getAdminMenus 사용 시기)에 생성된 + * 메뉴 row 가 DB 에 남아 있을 수 있어, 비활성화 시 currentSlugs=[] 로 cleanupStaleMenus + * 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로 + * 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리. * * @return bool 비활성화 성공 여부 */ @@ -120,7 +56,7 @@ class Plugin extends AbstractPlugin } /** - * 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리). + * 플러그인 제거 — 메뉴 잔재 안전망 (정상 흐름은 deactivate 가 먼저 처리). * * @return bool 제거 성공 여부 */ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx new file mode 100644 index 00000000..143fa0b8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx @@ -0,0 +1,236 @@ +/** + * 비즈뿌리오 메시징 플러그인 알림톡 템플릿 관리 탭 구조 검증 (Phase 5, §6-3·6-4) + * + * plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 관리 화면을 검증한다. + * - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(activeSettingsTab) + * - 목록 서브뷰(상태필터·검색·상태배지·상세) / 폼 서브뷰(유형별 필드·변수삽입) + * - 상세 모달 / readiness 안내 / 데이터소스 / i18n 정합 + * + * 독립 페이지·메뉴 없음(⚑ 결정 2·3): 설정 페이지 탭으로만 진입. + */ + +import { describe, it, expect } from 'vitest'; +import layout from '../../../layouts/admin/plugin_settings.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { findById, collectI18nKeys, type AnyNode } from './helpers'; + +const root = layout as unknown as AnyNode; + +describe('alimtalk templates — 탭 네비게이션', () => { + it('탭 네비게이션에 환경설정·알림톡 템플릿 탭 버튼이 있다', () => { + const nav = findById(root, 'settings_tabs'); + expect(nav).toBeTruthy(); + expect(findById(nav, 'tab_connection')).toBeTruthy(); + expect(findById(nav, 'tab_templates')).toBeTruthy(); + }); + + it('알림톡 탭 버튼이 query.tab=templates 로 URL 을 갱신하고 목록을 refetch 한다', () => { + const raw = JSON.stringify(findById(root, 'tab_templates')); + expect(raw).toContain('replaceUrl'); + expect(raw).toContain('"tab":"templates"'); + expect(raw).toContain('"templateView":"list"'); + expect(raw).toContain('alimtalk_templates'); + }); + + it('탭 패널이 query.tab 으로 배타 전환된다 (새로고침에도 유지)', () => { + const connection = findById(root, 'connection_tab_panel'); + const templates = findById(root, 'templates_tab_panel'); + expect((connection as { if?: string }).if).toContain("query.tab ?? 'connection') === 'connection'"); + expect((templates as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'"); + }); + + it('init_actions 가 templates 탭 진입 시 목록을 자동 로드한다 (새로고침 복원)', () => { + const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? []; + const refetch = inits.find((a) => a.handler === 'refetchDataSource'); + expect(refetch).toBeTruthy(); + expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'"); + }); +}); + +describe('alimtalk templates — 데이터소스', () => { + it('템플릿 목록·카테고리 데이터소스가 admin API 를 조회한다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const list = sources.find((s) => s.id === 'alimtalk_templates'); + const cats = sources.find((s) => s.id === 'alimtalk_categories'); + expect(list?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates'); + expect(list?.auto_fetch).toBe(false); + expect(cats?.endpoint).toContain('/alimtalk-templates/categories'); + }); +}); + +describe('alimtalk templates — 목록 서브뷰', () => { + it('목록 서브뷰가 templateView=list 조건으로 표시된다', () => { + const view = findById(root, 'templates_list_view'); + expect((view as { if?: string }).if).toContain("templateView ?? 'list') === 'list'"); + }); + + it('상태 필터 Select 와 검색 입력이 있다', () => { + const toolbar = findById(root, 'templates_toolbar'); + const raw = JSON.stringify(toolbar); + expect(raw).toContain('templateStatus'); + expect(raw).toContain('templateKeyword'); + }); + + it('빈 목록에서도 헤더 있는 표(카드)를 항상 표시하고 데이터 유무로 Tbody 를 분기한다', () => { + const card = findById(root, 'templates_table_card'); + expect(card).toBeTruthy(); + const raw = JSON.stringify(card); + // 표 헤더는 항상 존재 + expect(raw).toContain('columns.name'); + expect(raw).toContain('columns.status'); + // 데이터 유무 분기 (빈 상태 안내 + 목록 iteration) + expect(raw).toContain('.length > 0'); + expect(raw).toContain('.length === 0'); + expect(raw).toContain('templates.list.empty'); + }); + + it('표가 templates 목록을 iteration 으로 렌더한다', () => { + const raw = JSON.stringify(findById(root, 'templates_table_card')); + expect(raw).toContain('alimtalk_templates?.data?.templates'); + expect(raw).toContain('"item_var":"tpl"'); + expect(raw).toContain('tpl.templateName'); + expect(raw).toContain('status_badge'); + }); + + it('[새 템플릿] 버튼이 폼 서브뷰로 전환하고 폼을 초기화한다', () => { + const raw = JSON.stringify(findById(root, 'templates_toolbar')); + expect(raw).toContain('"templateView":"form"'); + expect(raw).toContain('templateEmphasizeType'); + }); +}); + +describe('alimtalk templates — 폼 서브뷰', () => { + it('폼 서브뷰가 templateView=form 조건으로 표시된다', () => { + const view = findById(root, 'templates_form_view'); + expect((view as { if?: string }).if).toContain("templateView === 'form'"); + }); + + it('공통 필드(이름·본문)가 수동바인딩(value+onChange)으로 존재한다', () => { + const raw = JSON.stringify(findById(root, 'templates_form_view')); + // 이름: value 바인딩 + templateForm.templateName setState (자동바인딩 name 대신) + expect(raw).toContain('templateForm.templateName'); + expect(raw).toContain('_local.templateForm?.templateName'); + // 본문: templateForm.templateContent 수동바인딩 + expect(raw).toContain('templateForm.templateContent'); + }); + + it('강조표기형(TEXT) 필드가 유형 조건으로 표시된다', () => { + const text = findById(root, 'template_text_fields'); + expect((text as { if?: string }).if).toContain("templateEmphasizeType === 'TEXT'"); + const raw = JSON.stringify(text); + expect(raw).toContain('templateTitle'); + expect(raw).toContain('templateSubtitle'); + }); + + it('이미지형(IMAGE) 필드가 유형 조건으로 표시된다', () => { + const image = findById(root, 'template_image_fields'); + expect((image as { if?: string }).if).toContain("templateEmphasizeType === 'IMAGE'"); + const raw = JSON.stringify(image); + expect(raw).toContain('templateImageName'); + expect(raw).toContain('templateImageUrl'); + }); + + it('유형 선택 버튼이 1차 지원 3종(NONE/IMAGE/TEXT)만 노출하고 ITEM_LIST 는 없다', () => { + const raw = JSON.stringify(findById(root, 'templates_form_view')); + // 버튼 방식: setState 로 templateEmphasizeType 을 직접 설정 + expect(raw).toContain('"templateForm.templateEmphasizeType":"NONE"'); + expect(raw).toContain('"templateForm.templateEmphasizeType":"IMAGE"'); + expect(raw).toContain('"templateForm.templateEmphasizeType":"TEXT"'); + expect(raw).not.toContain('ITEM_LIST'); + }); + + it('변수 버튼이 insertVariable 핸들러로 본문 상태(statePath)에 삽입한다', () => { + const raw = JSON.stringify(findById(root, 'template_variables')); + expect(raw).toContain('sirsoft-message_bizppurio.insertVariable'); + expect(raw).toContain('"target":"templateContent"'); + expect(raw).toContain('"statePath":"templateForm.templateContent"'); + }); + + it('이벤트별 변수 참고표가 토글 없이 항상 표시된다(인라인 배열 iteration)', () => { + const raw = JSON.stringify(findById(root, 'template_variables')); + // 참고표 iteration 이 인라인 배열(varref lang 라벨 + 변수 예시)을 렌더 + expect(raw).toContain('templates.varref.welcome'); + expect(raw).toContain('"item_var":"vref"'); + // showVarRef 토글 조건이 없어야 함(항상 표시) + expect(raw).not.toContain('showVarRef'); + }); + + it('선택 항목이 계획서 §6-4 6개(부가정보·미리보기·버튼·바로연결·대표링크·보안) 체크펼침으로 존재하고 헤더는 없다', () => { + const raw = JSON.stringify(findById(root, 'template_optional')); + expect(raw).toContain('templateForm.templateExtra'); + expect(raw).toContain('templateForm.templatePreviewMessage'); + expect(raw).toContain('templateForm.buttons'); + expect(raw).toContain('templateForm.quickReplies'); + expect(raw).toContain('templateForm.templateRepresentLink'); + expect(raw).toContain('templateForm.securityFlag'); + // 각 항목 체크박스 토글(_local.opt*) + expect(raw).toContain('optExtra'); + expect(raw).toContain('optButton'); + expect(raw).toContain('optLink'); + // 헤더(ITEM_LIST 전용, §6-4 선택항목 아님)는 제거됨 + expect(raw).not.toContain('templateForm.templateHeader'); + }); + + it('템플릿 코드(자동/직접)·카테고리 2단 드롭다운·이미지 파일첨부가 존재한다', () => { + const codeField = JSON.stringify(findById(root, 'template_code_field')); + expect(codeField).toContain('templateCodeMode'); + expect(codeField).toContain('"auto"'); + expect(codeField).toContain('"manual"'); + + const catField = JSON.stringify(findById(root, 'template_category_field')); + expect(catField).toContain('categoryGroup'); + expect(catField).toContain('templateForm.categoryCode'); + expect(catField).toContain('alimtalk_categories'); + + const imgField = JSON.stringify(findById(root, 'template_image_fields')); + expect(imgField).toContain('"type":"file"'); + expect(imgField).toContain('sirsoft-message_bizppurio.uploadTemplateImage'); + expect(imgField).toContain('templateImageUrl'); + }); + + it('저장 버튼이 [등록]과 [등록 후 검수요청] 2개로 분리된다', () => { + const raw = JSON.stringify(findById(root, 'templates_form_view')); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('_local.templateForm'); + expect(raw).toContain('"templateView":"list"'); + // 등록 후 검수요청: requestInspection 플래그를 body 에 실음 + expect(raw).toContain('requestInspection'); + expect(raw).toContain('save_register'); + expect(raw).toContain('save_and_request'); + }); +}); + +describe('alimtalk templates — 상세 모달 & readiness', () => { + it('상세 모달이 정의되어 있다', () => { + const modals = (root as { modals?: AnyNode[] }).modals ?? []; + const detail = modals.find((m) => m.id === 'alimtalk_template_detail_modal'); + expect(detail).toBeTruthy(); + }); + + it('readiness 안내가 api_key/sender_key 미설정 시 조건부로 표시된다', () => { + const readiness = findById(root, 'templates_readiness'); + const cond = (readiness as { if?: string }).if ?? ''; + expect(cond).toContain('api_key'); + expect(cond).toContain('sender_key'); + }); +}); + +describe('alimtalk templates — i18n 정합', () => { + it('레이아웃 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => { + const keys = collectI18nKeys(layout); + const resolve = (dict: Record, path: string): unknown => + path.split('.').reduce((acc, seg) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[seg]; + } + return undefined; + }, dict); + + for (const raw of keys) { + const path = raw.replace('$t:sirsoft-message_bizppurio.', ''); + expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy(); + expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy(); + } + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index f38145cc..7f63768d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -47,8 +47,8 @@ describe('plugin_settings.json — 레이아웃 메타/권한', () => { }); describe('plugin_settings.json — 자동바인딩', () => { - it('컨테이너가 dataKey=form + trackChanges 로 자동바인딩한다', () => { - const container = findById(root, 'plugin_settings_content'); + it('환경설정 탭 패널이 dataKey=form + trackChanges 로 자동바인딩한다', () => { + const container = findById(root, 'connection_tab_panel'); expect(container).toBeTruthy(); expect((container as { dataKey?: string }).dataKey).toBe('form'); expect((container as { trackChanges?: boolean }).trackChanges).toBe(true); @@ -154,7 +154,13 @@ describe('plugin_settings.json — 저장 버튼', () => { it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { const handlers = collectHandlers(layout); - const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView', 'copyToClipboard']; + const allowed = [ + 'apiCall', 'setState', 'toast', 'navigate', 'sequence', + 'refetchDataSource', 'scrollIntoView', 'copyToClipboard', + 'openModal', 'closeModal', 'replaceUrl', + 'sirsoft-message_bizppurio.insertVariable', + 'sirsoft-message_bizppurio.uploadTemplateImage', + ]; for (const h of handlers) { expect(allowed).toContain(h); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts new file mode 100644 index 00000000..5858cc2b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts @@ -0,0 +1,14 @@ +/** + * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵. + * + * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인 + * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable + */ + +import { insertVariableHandler } from './insertVariable'; +import { uploadTemplateImageHandler } from './uploadTemplateImage'; + +export const handlerMap = { + insertVariable: insertVariableHandler, + uploadTemplateImage: uploadTemplateImageHandler, +} as const; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts new file mode 100644 index 00000000..bd661ddf --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts @@ -0,0 +1,103 @@ +/** + * insertVariable 핸들러 + * + * 알림톡 템플릿 등록/수정 화면에서 "사용 가능 변수" 버튼을 클릭하면, 대상 본문의 현재 + * 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.`)를 SSoT + * 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌 + * (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다. + * + * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다. + * + * 레이아웃 JSON 사용 예: + * { + * "handler": "sirsoft-message_bizppurio.insertVariable", + * "params": { "variable": "name", "target": "templateContent", "statePath": "templateForm.templateContent" } + * } + */ + +import type { ActionContext, ActionWithParams } from '../types'; + +const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? { + log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args), + warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args), + error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args), +}; + +/** 삽입 대상 본문 필드(name) 기본값 */ +const DEFAULT_TARGET = 'templateContent'; + +/** 상태 경로 기본값 (_local 하위) */ +const DEFAULT_STATE_PATH = 'templateForm.templateContent'; + +/** + * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다. + * + * @param action 액션 객체(params.variable, params.target, params.statePath) + * @param context 액션 컨텍스트(state 스냅샷) + */ +export function insertVariableHandler( + action: ActionWithParams, + context: ActionContext, +): void { + const G7Core = (window as any).G7Core; + const params = action.params ?? {}; + const variable = String(params.variable ?? '').trim(); + const target = String(params.target ?? DEFAULT_TARGET); + const statePath = String(params.statePath ?? DEFAULT_STATE_PATH); + + if (variable === '') { + logger.warn('삽입할 변수명이 지정되지 않았습니다.'); + return; + } + + if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) { + logger.error('G7Core.state API 를 사용할 수 없습니다.'); + return; + } + + // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal. + const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {}; + const current = String(readPath(localState, statePath) ?? ''); + + const token = `#{${variable}}`; + + // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립. + const field = document.querySelector( + `textarea[name="${target}"], input[name="${target}"]`, + ); + const domValue = field?.value ?? ''; + // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다. + const inSync = domValue === current; + const start = inSync ? (field?.selectionStart ?? current.length) : current.length; + const end = inSync ? (field?.selectionEnd ?? current.length) : current.length; + + const next = current.slice(0, start) + token + current.slice(end); + + G7Core.state.setLocal({ [statePath]: next }); + + // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서). + if (field) { + const caret = start + token.length; + requestAnimationFrame(() => { + try { + field.focus(); + field.setSelectionRange(caret, caret); + } catch { + // setSelectionRange 미지원 input — 무시 + } + }); + } + + logger.log(`변수 삽입: ${token} → ${statePath}`); +} + +/** + * dot notation 경로로 객체에서 값을 읽습니다. (예: "templateForm.templateContent") + * + * @param obj 대상 객체 + * @param path dot 경로 + * @return 경로 값(없으면 undefined) + */ +function readPath(obj: Record, path: string): unknown { + return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts new file mode 100644 index 00000000..e7e626ae --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts @@ -0,0 +1,98 @@ +/** + * uploadTemplateImage 핸들러 + * + * 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의 + * 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을 + * `_local.templateForm.templateImageUrl` 에, 파일명을 `templateImageName` 에 설정한다. + * + * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다. + * 진행 상태는 `_local.imageUploading`, 오류는 `_local.imageUploadError` 로 노출한다. + * + * 레이아웃 JSON 사용 예: + * { + * "type": "change", + * "handler": "sirsoft-message_bizppurio.uploadTemplateImage" + * } + * (대상 file input 의 change 이벤트에 바인딩; $event.target.files[0] 를 사용) + */ + +import type { ActionContext, ActionWithParams } from '../types'; + +const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? { + log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args), + warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args), + error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args), +}; + +const UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image'; + +/** + * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다. + * + * @param action 액션 객체(미사용 params) + * @param context 액션 컨텍스트($event 로 파일 접근) + */ +export async function uploadTemplateImageHandler( + action: ActionWithParams, + context: ActionContext, +): Promise { + const G7Core = (window as any).G7Core; + if (!G7Core?.state?.setLocal) { + logger.error('G7Core.state.setLocal 을 사용할 수 없습니다.'); + return; + } + + const event = (context?.event ?? (action as any)?.event) as Event | undefined; + const input = event?.target as HTMLInputElement | undefined; + const file = input?.files?.[0]; + + if (!file) { + return; + } + + G7Core.state.setLocal({ imageUploading: true, imageUploadError: null }); + + try { + const token = localStorage.getItem('auth_token') ?? ''; + const form = new FormData(); + form.append('image', file, file.name); + + const res = await fetch(UPLOAD_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: form, + }); + + const json = await res.json().catch(() => ({})); + + if (!res.ok || json?.success === false) { + const message = json?.errors?.kakao_message || json?.message || '이미지 업로드에 실패했습니다.'; + G7Core.state.setLocal({ imageUploading: false, imageUploadError: message }); + logger.warn('이미지 업로드 실패:', message); + return; + } + + const url = json?.data?.image_url ?? ''; + const name = json?.data?.image_name ?? file.name; + + G7Core.state.setLocal({ + imageUploading: false, + imageUploadError: null, + 'templateForm.templateImageUrl': url, + 'templateForm.templateImageName': name, + }); + + logger.log('이미지 업로드 완료:', url); + } catch (e) { + G7Core.state.setLocal({ imageUploading: false, imageUploadError: String(e) }); + logger.error('이미지 업로드 예외:', e); + } finally { + // 같은 파일 재선택이 가능하도록 input 값 초기화 + if (input) { + input.value = ''; + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts new file mode 100644 index 00000000..ba8d309e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts @@ -0,0 +1,94 @@ +/** + * sirsoft-message_bizppurio 플러그인 엔트리포인트. + * + * 플러그인 활성화 시 자동 로드되어 커스텀 핸들러(insertVariable)를 ActionDispatcher 에 + * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다. + */ + +import { handlerMap } from './handlers'; + +const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + +const logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? { + log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), + warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), + error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args), +}; + +/** + * handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다. + * + * @param dispatcher 코어 ActionDispatcher 인스턴스 + */ +function register(dispatcher: any): void { + Object.entries(handlerMap).forEach(([name, handler]) => { + dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, { + category: 'plugin', + source: PLUGIN_IDENTIFIER, + }); + }); + + logger.log( + `${Object.keys(handlerMap).length} handler(s) registered:`, + Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`), + ); +} + +/** + * ActionDispatcher 준비 후 핸들러를 등록합니다. + * + * 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다. + * + * @param retry ActionDispatcher 부재 시 재시도 여부 + */ +function registerHandlers(retry: boolean): void { + const dispatcher = (window as any).G7Core?.getActionDispatcher?.(); + + if (dispatcher) { + register(dispatcher); + return; + } + + if (!retry) { + logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.'); + return; + } + + let count = 0; + const max = 50; // 최대 5초 (50 * 100ms) + const tick = () => { + const found = (window as any).G7Core?.getActionDispatcher?.(); + if (found) { + register(found); + return; + } + if (++count <= max) { + setTimeout(tick, 100); + } else { + logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.'); + } + }; + tick(); +} + +/** + * 플러그인 초기화 — DOM 준비 후 핸들러 등록. + */ +export function initPlugin(): void { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => registerHandlers(true)); + } else { + const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.(); + registerHandlers(!hasDispatcher); + } +} + +initPlugin(); + +if (typeof window !== 'undefined') { + (window as any).__SirsoftMessageBizppurio = { + identifier: PLUGIN_IDENTIFIER, + handlers: Object.keys(handlerMap), + initPlugin, + }; +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts new file mode 100644 index 00000000..a0610d1a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts @@ -0,0 +1,28 @@ +/** + * sirsoft-message_bizppurio 플러그인 프론트 타입 정의. + */ + +/** + * 액션 컨텍스트 인터페이스. + * + * ActionDispatcher 가 커스텀 핸들러 실행 시 전달하는 컨텍스트다. + */ +export interface ActionContext { + /** 현재 로컬 상태 가져오기 */ + getLocalState?: () => Record; + /** 로컬 상태 업데이트 */ + setLocalState?: (updates: Record) => void; + /** 이벤트 객체 */ + event?: Event; + /** 데이터 컨텍스트 */ + dataContext?: Record; +} + +/** + * 커스텀 핸들러 액션 객체(공통). + */ +export interface ActionWithParams { + handler: string; + params?: Record; + [key: string]: any; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index e3f026ab..a13ba160 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -56,6 +56,149 @@ "note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.", "copy": "Copy", "copied": "Report endpoint copied to clipboard." + }, + "tabs": { + "connection": "Settings", + "templates": "Alimtalk Templates" + } + }, + "templates": { + "title": "Alimtalk Templates", + "description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.", + "readiness": { + "title": "The following settings are required to use alimtalk templates", + "go_settings": "Go to settings", + "missing_label": "Missing", + "api_key_missing": "Kakao management API key", + "sender_key_missing": "Alimtalk sender profile key", + "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao." + }, + "list": { + "new": "New Template", + "refresh": "Refresh", + "search_placeholder": "Search by name (2-50 chars)", + "filter_all": "All statuses", + "empty": "No alimtalk templates registered.", + "empty_hint": "Register an alimtalk template with the [New Template] button.", + "load_failed": "Failed to load templates. Check the sender profile key and API key.", + "columns": { + "name": "Name", + "code": "Code", + "status": "Status", + "actions": "Actions" + } + }, + "status": { + "sendable": "Sendable", + "inspecting": "Inspecting", + "rejected": "Rejected", + "uninspected": "Not inspected", + "stopped": "Stopped", + "blocked": "Blocked", + "dormant": "Dormant", + "unknown": "Unknown" + }, + "actions": { + "edit": "Edit", + "delete": "Delete", + "request": "Request", + "cancel_request": "Cancel request", + "stop": "Stop", + "reuse": "Resume", + "cancel_approval": "Cancel approval", + "release": "Release", + "detail": "Detail" + }, + "form": { + "title_new": "Register Alimtalk Template", + "title_edit": "Edit Alimtalk Template", + "back_to_list": "Back to list", + "sender_key": "Sender profile key", + "code": "Template code", + "code_auto": "Auto-generate", + "code_manual": "Enter manually", + "code_hint": "Letters, digits, underscore(_), hyphen(-), max 30 chars", + "name": "Template name", + "name_hint": "Management name (not part of content), max 200 chars", + "category": "Category", + "category_group": "Group", + "category_item": "Item", + "emphasize_type": "Template type", + "type_none": "Basic", + "type_image": "Image", + "type_text": "Highlighted", + "type_item_list_notice": "Advanced templates such as item-list should be registered on the Bizppurio website. Templates registered there are also available for listing and bindings.", + "goto_bizppurio": "Bizppurio website", + "image_name": "Image file name", + "image_url": "Image URL", + "image_upload": "Attach image", + "image_upload_hint": "jpg/png / max 500KB / at least 500px wide (2:1 to 3:4 ratio)", + "image_uploading": "Uploading image...", + "image_remove": "Remove image", + "image_maker_notice": "The image maker (auto-generation tool) is provided in the Bizppurio console.", + "title_field": "Highlight title", + "title_hint": "Only content present in the body, max 23 chars (variables allowed)", + "subtitle_field": "Highlight subtitle", + "subtitle_hint": "Only content present in the body, max 18 chars (no variables)", + "content": "Template content", + "content_placeholder": "Enter the body. Insert variables with #{variable}.", + "content_hint": "Max 1300 chars", + "variables_title": "Insert variable", + "variables_help": "Insert #{variable} into the body; when you bind this template to a notification, it is replaced with the real value (member name, order number, etc.). Below are example variables per notification — insert the ones matching the notification you will bind.", + "variables_quick": "Common variables (click to insert at cursor)", + "variables_ref_toggle": "Variable reference by notification", + "variables_ref_event": "Notification", + "variables_ref_vars": "Provided variables", + "optional_title": "Optional fields", + "optional_toggle": "Expand optional fields (extra info, buttons, etc.)", + "optional_help": "Check only the items you need to reveal their inputs. Buttons and quick replies assume a single web link (WL).", + "button": "Button", + "button_name": "Button label", + "button_url": "Mobile link URL", + "button_hint": "A web-link button shown at the bottom of the message.", + "quick_reply": "Quick reply", + "quick_reply_hint": "Quick reply (web link) used on consult-talk channels.", + "represent_link": "Represent link", + "link_pc": "PC web link", + "link_mo": "Mobile web link", + "preview_message": "Preview message", + "preview_message_hint": "Summary shown in the chat list (max 40 chars).", + "extra": "Extra info", + "extra_hint": "Supplementary text outside the body such as channel-add or ad notices (registers the message type as extra-info type).", + "security_flag": "Secure template (OTP, etc.)", + "security_flag_hint": "Turn on for secure messages such as OTP. The message text is hidden on devices other than the sending device.", + "preview_title": "Preview", + "preview_empty": "Enter the body to see a preview here.", + "save_register": "Register", + "save_register_hint": "Registers as a draft only. To send, request inspection from the list and get Kakao approval.", + "save_and_request": "Register and request inspection", + "save_and_request_hint": "Registers and requests Kakao inspection at once. You cannot edit after requesting." + }, + "detail": { + "title": "Template detail", + "close": "Close" + }, + "confirm": { + "delete": "Delete this template?", + "request": "Request inspection? You cannot edit after requesting.", + "cancel_request": "Cancel the inspection request?", + "stop": "Stop this template? It cannot be used for delivery once stopped.", + "cancel_approval": "Cancel the approval? Re-inspection will be required." + }, + "toast": { + "created": "The alimtalk template has been registered.", + "created_requested": "The alimtalk template has been registered and inspection requested.", + "updated": "The alimtalk template has been updated.", + "deleted": "The alimtalk template has been deleted.", + "action_success": "The request has been processed.", + "action_failed": "Failed to process the request." + }, + "varref": { + "welcome": "Welcome (signup)", + "reset_password": "Password reset", + "order_confirmed": "Order confirmed", + "order_shipped": "Order shipped", + "new_comment": "New comment" } } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 4e585d3d..8dca6795 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -56,6 +56,149 @@ "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.", "copy": "복사", "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." + }, + "tabs": { + "connection": "환경설정", + "templates": "알림톡 템플릿" + } + }, + "templates": { + "title": "알림톡 템플릿", + "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", + "readiness": { + "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다", + "go_settings": "환경설정으로 이동", + "missing_label": "미설정 항목", + "api_key_missing": "카카오 관리 API 키", + "sender_key_missing": "알림톡 발신프로필 키", + "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다." + }, + "list": { + "new": "새 템플릿", + "refresh": "새로고침", + "search_placeholder": "템플릿명 검색 (2~50자)", + "filter_all": "전체 상태", + "empty": "등록된 알림톡 템플릿이 없습니다.", + "empty_hint": "[새 템플릿] 버튼으로 알림톡 템플릿을 등록하세요.", + "load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.", + "columns": { + "name": "템플릿명", + "code": "코드", + "status": "상태", + "actions": "관리" + } + }, + "status": { + "sendable": "발송가능", + "inspecting": "검수중", + "rejected": "반려", + "uninspected": "미검수", + "stopped": "중지", + "blocked": "차단", + "dormant": "휴면", + "unknown": "알 수 없음" + }, + "actions": { + "edit": "수정", + "delete": "삭제", + "request": "검수요청", + "cancel_request": "검수취소", + "stop": "중지", + "reuse": "중지해제", + "cancel_approval": "승인취소", + "release": "휴면해제", + "detail": "상세" + }, + "form": { + "title_new": "알림톡 템플릿 등록", + "title_edit": "알림톡 템플릿 수정", + "back_to_list": "목록으로", + "sender_key": "발신프로필 키", + "code": "템플릿 코드", + "code_auto": "자동 생성", + "code_manual": "직접 입력", + "code_hint": "영문·숫자·언더바(_)·하이픈(-), 최대 30자", + "name": "템플릿명", + "name_hint": "관리용 이름 (내용에 포함되지 않음), 최대 200자", + "category": "카테고리", + "category_group": "대분류", + "category_item": "소분류", + "emphasize_type": "템플릿 유형", + "type_none": "기본형", + "type_image": "이미지형", + "type_text": "강조표기형", + "type_item_list_notice": "아이템리스트형 등 고급 템플릿은 비즈뿌리오 홈페이지에서 등록하세요. 홈페이지에서 등록한 템플릿도 목록 조회·연동에서 그대로 사용할 수 있습니다.", + "goto_bizppurio": "비즈뿌리오 홈페이지", + "image_name": "이미지 파일명", + "image_url": "이미지 링크", + "image_upload": "이미지 첨부", + "image_upload_hint": "jpg·png / 최대 500KB / 가로 500px 이상 (가로:세로 2:1~3:4)", + "image_uploading": "이미지 업로드 중...", + "image_remove": "이미지 제거", + "image_maker_notice": "이미지 메이커(자동 생성 도구)는 비즈뿌리오 콘솔에서 제공됩니다.", + "title_field": "강조표기 문구", + "title_hint": "본문에 있는 내용만, 최대 23자 (변수 사용 가능)", + "subtitle_field": "보조 문구", + "subtitle_hint": "본문에 있는 내용만, 최대 18자 (변수 사용 불가)", + "content": "템플릿 내용", + "content_placeholder": "본문을 입력하세요. #{변수} 형식으로 변수를 넣을 수 있습니다.", + "content_hint": "최대 1300자", + "variables_title": "변수 넣기", + "variables_help": "본문에 #{변수} 형식으로 넣으면, 이 템플릿을 알림에 연결할 때 실제 값(회원 이름·주문번호 등)으로 자동 치환됩니다. 아래는 알림별 제공 변수 예시입니다 — 연결할 알림에 맞는 변수를 본문에 넣으세요.", + "variables_quick": "자주 쓰는 변수 (클릭하면 커서 위치에 삽입)", + "variables_ref_toggle": "알림별 제공 변수 참고표", + "variables_ref_event": "알림", + "variables_ref_vars": "제공 변수", + "optional_title": "선택 항목", + "optional_toggle": "선택 항목 펼치기 (부가정보·버튼 등)", + "optional_help": "필요한 항목만 체크하면 입력란이 나타납니다. 버튼·바로연결은 웹링크(WL) 1개 기준입니다.", + "button": "버튼", + "button_name": "버튼명", + "button_url": "모바일 링크 URL", + "button_hint": "메시지 하단에 표시되는 웹링크 버튼입니다.", + "quick_reply": "바로연결", + "quick_reply_hint": "상담톡 채널에서 사용하는 바로연결(웹링크)입니다.", + "represent_link": "대표 링크", + "link_pc": "PC 웹 링크", + "link_mo": "모바일 웹 링크", + "preview_message": "미리보기 메시지", + "preview_message_hint": "채팅 목록에 표시되는 요약 문구 (최대 40자)", + "extra": "부가정보", + "extra_hint": "채널 추가·광고성 안내 등 본문 외 부가 문구 (입력 시 메시지 유형이 부가정보형으로 등록됩니다)", + "security_flag": "템플릿 보안 (OTP 등)", + "security_flag_hint": "OTP 등 보안 메시지일 때 켜세요. 발신 디바이스 외 다른 기기에는 메시지 텍스트가 표시되지 않습니다.", + "preview_title": "미리보기", + "preview_empty": "본문을 입력하면 여기에 미리보기가 표시됩니다.", + "save_register": "등록", + "save_register_hint": "임시 등록만 합니다. 발송하려면 이후 목록에서 검수요청 → 카카오 승인이 필요합니다.", + "save_and_request": "등록 후 검수요청", + "save_and_request_hint": "등록과 동시에 카카오에 검수를 요청합니다. 요청 후에는 수정할 수 없습니다." + }, + "detail": { + "title": "템플릿 상세", + "close": "닫기" + }, + "confirm": { + "delete": "이 템플릿을 삭제하시겠습니까?", + "request": "검수를 요청하시겠습니까? 요청 후에는 수정할 수 없습니다.", + "cancel_request": "검수 요청을 취소하시겠습니까?", + "stop": "이 템플릿을 중지하시겠습니까? 중지하면 발송에 사용할 수 없습니다.", + "cancel_approval": "승인을 취소하시겠습니까? 취소하면 재검수가 필요합니다." + }, + "toast": { + "created": "알림톡 템플릿을 등록했습니다.", + "created_requested": "알림톡 템플릿을 등록하고 검수를 요청했습니다.", + "updated": "알림톡 템플릿을 수정했습니다.", + "deleted": "알림톡 템플릿을 삭제했습니다.", + "action_success": "요청을 처리했습니다.", + "action_failed": "요청 처리에 실패했습니다." + }, + "varref": { + "welcome": "회원가입 환영", + "reset_password": "비밀번호 재설정", + "order_confirmed": "결제 완료", + "order_shipped": "배송 시작", + "new_comment": "새 댓글" } } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index bacd2c55..317f819a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -9,6 +9,13 @@ "title": "$t:sirsoft-message_bizppurio.settings.title", "description": "$t:sirsoft-message_bizppurio.settings.description" }, + "init_actions": [ + { + "if": "{{(query.tab ?? 'connection') === 'templates'}}", + "handler": "refetchDataSource", + "params": { "dataSourceId": "alimtalk_templates" } + } + ], "data_sources": [ { "id": "settings", @@ -28,16 +35,34 @@ "auto_fetch": true, "auth_required": true, "initLocal": "reportInfo" + }, + { + "id": "alimtalk_templates", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "params": { + "status": "{{_local.templateStatus ?? ''}}", + "keyword": "{{_local.templateKeyword ?? ''}}" + } + }, + { + "id": "alimtalk_categories", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories", + "method": "GET", + "auto_fetch": false, + "auth_required": true } ], "slots": { "content": [ { - "id": "plugin_settings_content", + "id": "plugin_settings_page", "type": "basic", "name": "Div", - "dataKey": "form", - "trackChanges": true, "props": { "className": "admin-page-content-responsive" }, @@ -54,7 +79,74 @@ } }, { - "id": "info_panel", + "id": "settings_tabs", + "type": "basic", + "name": "Div", + "props": { "className": "mt-6 border-b border-gray-200 dark:border-gray-700 flex gap-1" }, + "children": [ + { + "id": "tab_connection", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-4 py-2.5 text-sm font-medium border-b-2 transition-colors {{(query.tab ?? 'connection') === 'connection' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}}" + }, + "actions": [ + { + "type": "click", + "handler": "replaceUrl", + "params": { "query": { "tab": "connection" }, "mergeQuery": true } + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.tabs.connection" } + ] + }, + { + "id": "tab_templates", + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-4 py-2.5 text-sm font-medium border-b-2 transition-colors {{(query.tab ?? 'connection') === 'templates' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}}" + }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { + "handler": "replaceUrl", + "params": { "query": { "tab": "templates" }, "mergeQuery": true } + }, + { + "handler": "setState", + "params": { "target": "local", "templateView": "list" } + }, + { + "handler": "refetchDataSource", + "params": { "dataSourceId": "alimtalk_templates" } + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.tabs.templates" } + ] + } + ] + }, + { + "id": "connection_tab_panel", + "type": "basic", + "name": "Div", + "if": "{{(query.tab ?? 'connection') === 'connection'}}", + "dataKey": "form", + "trackChanges": true, + "children": [ + { + "id": "info_panel", "type": "basic", "name": "Div", "props": { @@ -819,7 +911,1127 @@ ] } ] + }, + { + "id": "templates_tab_panel", + "type": "basic", + "name": "Div", + "if": "{{(query.tab ?? 'connection') === 'templates'}}", + "props": { "className": "mt-6" }, + "children": [ + { + "id": "templates_readiness", + "type": "basic", + "name": "Div", + "if": "{{!(settings?.data?.settings?.api_key) || !(settings?.data?.settings?.sender_key)}}", + "props": { "className": "mb-4 rounded-lg border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-between gap-3" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2 items-start" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "triangle-exclamation", "size": "md", "className": "text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "props": { "className": "text-sm font-medium text-amber-800 dark:text-amber-200" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.title" + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-1.5 flex-wrap mt-1.5" }, + "children": [ + { + "type": "basic", + "name": "Span", + "if": "{{!(settings?.data?.settings?.api_key)}}", + "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.api_key_missing" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!(settings?.data?.settings?.sender_key)}}", + "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.sender_key_missing" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary btn-sm whitespace-nowrap" }, + "actions": [ + { "type": "click", "handler": "replaceUrl", "params": { "query": { "tab": "connection" }, "mergeQuery": true } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.readiness.go_settings" } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs text-amber-700 dark:text-amber-300 mt-2 leading-relaxed" }, + "text": "$t:sirsoft-message_bizppurio.templates.readiness.note" + } + ] + }, + { + "id": "templates_list_view", + "type": "basic", + "name": "Div", + "if": "{{(_local.templateView ?? 'list') === 'list'}}", + "children": [ + { + "id": "templates_toolbar", + "type": "basic", + "name": "Div", + "props": { "className": "flex-between gap-3 mb-4 flex-wrap" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2 items-center flex-wrap" }, + "children": [ + { + "type": "basic", + "name": "Select", + "props": { + "name": "templateStatus", + "value": "{{_local.templateStatus ?? ''}}", + "className": "input w-auto" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateStatus": "{{$event.target.value}}" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ] + } + ], + "children": [ + { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.list.filter_all" }, + { "type": "basic", "name": "Option", "props": { "value": "ACT" }, "text": "$t:sirsoft-message_bizppurio.templates.status.sendable" }, + { "type": "basic", "name": "Option", "props": { "value": "REQ" }, "text": "$t:sirsoft-message_bizppurio.templates.status.inspecting" }, + { "type": "basic", "name": "Option", "props": { "value": "REJ" }, "text": "$t:sirsoft-message_bizppurio.templates.status.rejected" }, + { "type": "basic", "name": "Option", "props": { "value": "REG" }, "text": "$t:sirsoft-message_bizppurio.templates.status.uninspected" }, + { "type": "basic", "name": "Option", "props": { "value": "STP" }, "text": "$t:sirsoft-message_bizppurio.templates.status.stopped" } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "text", + "value": "{{_local.templateKeyword ?? ''}}", + "placeholder": "$t:sirsoft-message_bizppurio.templates.list.search_placeholder", + "className": "input w-64" + }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}" } } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary btn-sm" }, + "actions": [ + { "type": "click", "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "rotate", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.refresh" } + ] + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-primary btn-sm flex-center gap-1.5" }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateView": "form", "templateForm": { "templateEmphasizeType": "NONE", "templateName": "", "templateContent": "", "categoryCode": "" }, "editingCode": null, "templateErrors": null, "templateSaving": false, "templateCodeMode": "auto", "categoryGroup": "", "showVarRef": false, "showOptional": false } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_categories" } } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "plus", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.new" } + ] + } + ] + }, + { + "id": "templates_table_card", + "type": "basic", + "name": "Div", + "props": { "className": "overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800" }, + "children": [ + { + "type": "basic", + "name": "Table", + "props": { "className": "w-full text-sm" }, + "children": [ + { + "type": "basic", + "name": "Thead", + "props": { "className": "bg-gray-50 dark:bg-gray-700/50" }, + "children": [ + { + "type": "basic", + "name": "Tr", + "children": [ + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.name" }, + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.code" }, + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.status" }, + { "type": "basic", "name": "Th", "props": { "className": "text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.actions" } + ] + } + ] + }, + { + "type": "basic", + "name": "Tbody", + "if": "{{(alimtalk_templates?.data?.templates ?? []).length > 0}}", + "iteration": { + "source": "alimtalk_templates?.data?.templates ?? []", + "item_var": "tpl" + }, + "children": [ + { + "type": "basic", + "name": "Tr", + "props": { "className": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40" }, + "children": [ + { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-medium text-gray-900 dark:text-gray-100" }, "text": "{{tpl.templateName ?? ''}}" }, + { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400" }, "text": "{{tpl.templateCode ?? ''}}" }, + { + "type": "basic", + "name": "Td", + "props": { "className": "px-4 py-3" }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{tpl.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : tpl.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : tpl.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : tpl.status_badge?.variant === 'purple' ? 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}}" + }, + "text": "{{$t(tpl.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" + } + ] + }, + { + "type": "basic", + "name": "Td", + "props": { "className": "px-4 py-3 text-right" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-ghost btn-xs" }, + "actions": [ + { "type": "click", "handler": "apiCall", "auth_required": true, "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{{tpl.templateCode}}", "params": { "method": "GET" }, "onSuccess": [ { "handler": "setState", "params": { "target": "local", "templateDetail": "{{response.data.template}}" } }, { "handler": "openModal", "target": "alimtalk_template_detail_modal" } ] } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.actions.detail" } + ] + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Tbody", + "if": "{{(alimtalk_templates?.data?.templates ?? []).length === 0}}", + "children": [ + { + "type": "basic", + "name": "Tr", + "children": [ + { + "type": "basic", + "name": "Td", + "props": { "className": "px-4 py-12 text-center text-gray-400 dark:text-gray-500", "colSpan": 4 }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "font-medium" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty" }, + { "type": "basic", "name": "P", "props": { "className": "text-xs mt-1" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty_hint" } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "templates_form_view", + "type": "basic", + "name": "Div", + "if": "{{_local.templateView === 'form'}}", + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex-between mb-4" }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { "className": "section-heading-md text-gray-900 dark:text-gray-100" }, + "text": "{{_local.editingCode ? $t('sirsoft-message_bizppurio.templates.form.title_edit') : $t('sirsoft-message_bizppurio.templates.form.title_new')}}" + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-ghost btn-sm" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateView": "list" } } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "arrow-left", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.back_to_list" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-2 gap-6" }, + "children": [ + { + "id": "template_form_fields", + "type": "basic", + "name": "Div", + "props": { "className": "space-y-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "text", "maxLength": 200, "className": "input", "value": "{{_local.templateForm?.templateName ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateName": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name_hint" } + ] + }, + { + "id": "template_code_field", + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "inline-flex gap-2 mt-1 mb-2" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateCodeMode ?? 'auto') === 'auto' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateCodeMode": "auto", "templateForm.templateCode": "" } } + ], + "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.code_auto" } ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateCodeMode ?? 'auto') === 'manual' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateCodeMode": "manual" } } + ], + "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.code_manual" } ] + } + ] + }, + { + "type": "basic", + "name": "Input", + "if": "{{_local.templateCodeMode === 'manual'}}", + "props": { "type": "text", "maxLength": 30, "className": "input font-mono", "placeholder": "TW_1234", "value": "{{_local.templateForm?.templateCode ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateCode": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code_hint" } + ] + }, + { + "id": "template_category_field", + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "grid grid-cols-2 gap-2" }, + "children": [ + { + "type": "basic", + "name": "Select", + "props": { "className": "input", "value": "{{_local.categoryGroup ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "categoryGroup": "{{$event.target.value}}", "templateForm.categoryCode": "" } } + ], + "children": [ + { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category_group" }, + { + "type": "basic", + "name": "Option", + "iteration": { + "source": "[...new Set((alimtalk_categories?.data?.categories ?? []).map(c => c.groupName))]", + "item_var": "grp" + }, + "props": { "value": "{{grp}}" }, + "text": "{{grp}}" + } + ] + }, + { + "type": "basic", + "name": "Select", + "props": { "className": "input", "disabled": "{{!_local.categoryGroup}}", "value": "{{_local.templateForm?.categoryCode ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.categoryCode": "{{$event.target.value}}" } } + ], + "children": [ + { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category_item" }, + { + "type": "basic", + "name": "Option", + "iteration": { + "source": "(alimtalk_categories?.data?.categories ?? []).filter(c => c.groupName === _local.categoryGroup)", + "item_var": "cat" + }, + "props": { "value": "{{cat.code}}" }, + "text": "{{cat.name}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.emphasize_type" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "inline-flex gap-2 mt-1" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" + }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "NONE" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_none" } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" + }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "IMAGE" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_image" } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" + }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "TEXT" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_text" } + ] + } + ] + } + ] + }, + { + "id": "template_text_fields", + "type": "basic", + "name": "Div", + "if": "{{_local.templateForm?.templateEmphasizeType === 'TEXT'}}", + "props": { "className": "space-y-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.title_field" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "text", "maxLength": 23, "className": "input", "value": "{{_local.templateForm?.templateTitle ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateTitle": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.title_hint" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.subtitle_field" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "text", "maxLength": 18, "className": "input", "value": "{{_local.templateForm?.templateSubtitle ?? ''}}" }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateSubtitle": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.subtitle_hint" } + ] + } + ] + }, + { + "id": "template_image_fields", + "type": "basic", + "name": "Div", + "if": "{{_local.templateForm?.templateEmphasizeType === 'IMAGE'}}", + "props": { "className": "space-y-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_upload" }, + { + "type": "basic", + "name": "Input", + "props": { "type": "file", "accept": ".jpg,.jpeg,.png", "className": "block w-full text-sm text-gray-600 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:bg-blue-50 dark:file:bg-blue-900/40 file:text-blue-700 dark:file:text-blue-300 file:cursor-pointer" }, + "actions": [ + { "type": "change", "handler": "sirsoft-message_bizppurio.uploadTemplateImage" } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_upload_hint" } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{_local.imageUploading}}", + "props": { "className": "text-sm text-blue-600 dark:text-blue-400 flex items-center gap-1.5" }, + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "spinner", "className": "spinner-sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.image_uploading" } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{_local.imageUploadError}}", + "props": { "className": "text-sm text-red-600 dark:text-red-400" }, + "text": "{{_local.imageUploadError}}" + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.templateForm?.templateImageUrl}}", + "props": { "className": "flex items-center gap-3 p-2 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600" }, + "children": [ + { "type": "basic", "name": "Img", "props": { "src": "{{_local.templateForm?.templateImageUrl}}", "className": "w-16 h-16 object-cover rounded" } }, + { + "type": "basic", + "name": "Div", + "props": { "className": "min-w-0 flex-1" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-700 dark:text-gray-200 truncate" }, "text": "{{_local.templateForm?.templateImageName ?? ''}}" }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "text-xs text-red-600 dark:text-red-400 underline mt-0.5" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateImageUrl": "", "templateForm.templateImageName": "" } } + ], + "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.image_remove" } ] + } + ] + } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_maker_notice" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content" }, + { + "type": "basic", + "name": "Textarea", + "props": { + "rows": 14, + "maxLength": 1300, + "placeholder": "$t:sirsoft-message_bizppurio.templates.form.content_placeholder", + "className": "textarea font-mono resize-y leading-relaxed", + "value": "{{_local.templateForm?.templateContent ?? ''}}" + }, + "actions": [ + { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateContent": "{{$event.target.value}}" } } + ] + }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content_hint" } + ] + }, + { + "id": "template_variables", + "type": "basic", + "name": "Div", + "props": { "className": "rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 p-3 space-y-2" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm font-medium text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_title" }, + { "type": "basic", "name": "P", "props": { "className": "text-xs text-blue-800 dark:text-blue-300 leading-relaxed" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_help" }, + { "type": "basic", "name": "P", "props": { "className": "text-xs text-blue-700 dark:text-blue-300 mt-1" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_quick" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-1.5 flex-wrap" }, + "iteration": { + "source": "['name', 'app_name', 'order_number', 'site_url']", + "item_var": "varName" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "px-2 py-1 rounded bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300 text-xs font-mono hover:bg-blue-100 dark:hover:bg-blue-900/50" }, + "actions": [ + { "type": "click", "handler": "sirsoft-message_bizppurio.insertVariable", "params": { "variable": "{{varName}}", "target": "templateContent", "statePath": "templateForm.templateContent" } } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "{{'#{' + varName + '}'}}" } + ] + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs font-medium text-blue-800 dark:text-blue-300 mt-2" }, + "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_toggle" + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "overflow-x-auto mt-1 rounded border border-blue-100 dark:border-blue-800 bg-white/60 dark:bg-gray-900/40" }, + "children": [ + { + "type": "basic", + "name": "Table", + "props": { "className": "w-full text-xs" }, + "children": [ + { + "type": "basic", + "name": "Thead", + "children": [ + { + "type": "basic", + "name": "Tr", + "children": [ + { "type": "basic", "name": "Th", "props": { "className": "text-left px-2 py-1 text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_event" }, + { "type": "basic", "name": "Th", "props": { "className": "text-left px-2 py-1 text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_vars" } + ] + } + ] + }, + { + "type": "basic", + "name": "Tbody", + "iteration": { + "source": "[{event: $t('sirsoft-message_bizppurio.templates.varref.welcome'), vars: '#{name} #{app_name} #{action_url} #{site_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.reset_password'), vars: '#{name} #{app_name} #{action_url} #{expire_minutes} #{site_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.order_confirmed'), vars: '#{name} #{app_name} #{order_number} #{total_amount} #{order_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.order_shipped'), vars: '#{name} #{app_name} #{order_number} #{carrier_name} #{tracking_number} #{order_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.new_comment'), vars: '#{name} #{app_name} #{board_name} #{post_title} #{comment_author} #{post_url}'}]", + "item_var": "vref" + }, + "children": [ + { + "type": "basic", + "name": "Tr", + "props": { "className": "border-t border-blue-100 dark:border-blue-800" }, + "children": [ + { "type": "basic", "name": "Td", "props": { "className": "px-2 py-1 text-blue-800 dark:text-blue-300 whitespace-nowrap" }, "text": "{{vref.event}}" }, + { "type": "basic", "name": "Td", "props": { "className": "px-2 py-1 text-blue-700 dark:text-blue-300 font-mono" }, "text": "{{vref.vars}}" } + ] + } + ] + } + ] + } + ] + } + ] + }, + { + "id": "template_optional", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "text-sm text-gray-600 dark:text-gray-300 flex items-center gap-1" }, + "actions": [ + { "type": "click", "handler": "setState", "params": { "target": "local", "showOptional": "{{!_local.showOptional}}" } } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "{{_local.showOptional ? 'chevron-down' : 'chevron-right'}}", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.optional_toggle" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.showOptional}}", + "props": { "className": "mt-2 space-y-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "form-hint mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.form.optional_help" }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optExtra}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optExtra": "{{$event.target.checked}}", "templateForm.templateExtra": "" } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.extra" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optExtra}}", + "props": { "className": "mt-1.5 pl-6" }, + "children": [ + { "type": "basic", "name": "Textarea", "props": { "rows": 2, "maxLength": 500, "className": "textarea resize-y", "value": "{{_local.templateForm?.templateExtra ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateExtra": "{{$event.target.value}}" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.extra_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optPreview}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optPreview": "{{$event.target.checked}}", "templateForm.templatePreviewMessage": "" } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.preview_message" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optPreview}}", + "props": { "className": "mt-1.5 pl-6" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 40, "className": "input", "value": "{{_local.templateForm?.templatePreviewMessage ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templatePreviewMessage": "{{$event.target.value}}" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.preview_message_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optButton}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optButton": "{{$event.target.checked}}", "templateForm.buttons": [] } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.button" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optButton}}", + "props": { "className": "mt-1.5 pl-6 grid grid-cols-1 sm:grid-cols-3 gap-2" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 20, "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_name", "value": "{{_local.templateForm?.buttons?.[0]?.name ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.buttons.0.name": "{{$event.target.value}}", "templateForm.buttons.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input sm:col-span-2", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_url", "value": "{{_local.templateForm?.buttons?.[0]?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.buttons.0.linkMo": "{{$event.target.value}}", "templateForm.buttons.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint sm:col-span-3" }, "text": "$t:sirsoft-message_bizppurio.templates.form.button_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optQuick}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optQuick": "{{$event.target.checked}}", "templateForm.quickReplies": [] } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.quick_reply" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optQuick}}", + "props": { "className": "mt-1.5 pl-6 grid grid-cols-1 sm:grid-cols-3 gap-2" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 20, "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_name", "value": "{{_local.templateForm?.quickReplies?.[0]?.name ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.quickReplies.0.name": "{{$event.target.value}}", "templateForm.quickReplies.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input sm:col-span-2", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_url", "value": "{{_local.templateForm?.quickReplies?.[0]?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.quickReplies.0.linkMo": "{{$event.target.value}}", "templateForm.quickReplies.0.linkType": "WL" } } ] }, + { "type": "basic", "name": "P", "props": { "className": "form-hint sm:col-span-3" }, "text": "$t:sirsoft-message_bizppurio.templates.form.quick_reply_hint" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optLink}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optLink": "{{$event.target.checked}}", "templateForm.templateRepresentLink": {} } } ] }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.represent_link" } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.optLink}}", + "props": { "className": "mt-1.5 pl-6 space-y-2" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.link_pc", "value": "{{_local.templateForm?.templateRepresentLink?.linkPc ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateRepresentLink.linkPc": "{{$event.target.value}}" } } ] }, + { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.link_mo", "value": "{{_local.templateForm?.templateRepresentLink?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateRepresentLink.linkMo": "{{$event.target.value}}" } } ] } + ] + } + ] + }, + { + "type": "basic", + "name": "Label", + "props": { "className": "flex items-start gap-2 cursor-pointer pt-1" }, + "children": [ + { "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "mt-0.5", "checked": "{{!!_local.templateForm?.securityFlag}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.securityFlag": "{{$event.target.checked}}" } } ] }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-700 dark:text-gray-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.security_flag" }, + { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.security_flag_hint" } + ] + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "if": "{{_local.templateErrors}}", + "props": { "className": "alert-danger" }, + "children": [ + { + "type": "basic", + "name": "Ul", + "props": { "className": "list-disc list-inside space-y-1" }, + "iteration": { + "source": "Object.values(_local.templateErrors ?? {}).flatMap(m => Array.isArray(m) ? m : [m])", + "item_var": "msg" + }, + "children": [ + { "type": "basic", "name": "Li", "props": { "className": "text-error-soft text-sm" }, "text": "{{msg}}" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex flex-col gap-2 pt-2 border-t border-gray-100 dark:border-gray-700" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "disabled": "{{_local.templateSaving}}", "className": "btn btn-secondary flex-1" }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateErrors": null, "templateSaving": true } }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", + "params": { "method": "POST", "body": "{{_local.templateForm}}" }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false } }, + { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.templates.toast.created" } }, + { "handler": "setState", "params": { "target": "local", "templateView": "list" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ], + "onError": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false, "templateErrors": "{{error.errors ?? { _general: error.message }}}" } }, + { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.templates.toast.action_failed" } } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.save_register" } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "disabled": "{{_local.templateSaving}}", "className": "btn btn-primary flex-1" }, + "actions": [ + { + "type": "click", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateErrors": null, "templateSaving": true } }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", + "params": { "method": "POST", "body": "{{Object.assign({}, _local.templateForm, { requestInspection: true })}}" }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false } }, + { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.templates.toast.created_requested" } }, + { "handler": "setState", "params": { "target": "local", "templateView": "list" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ], + "onError": [ + { "handler": "setState", "params": { "target": "local", "templateSaving": false, "templateErrors": "{{error.errors ?? { _general: error.message }}}" } }, + { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.templates.toast.action_failed" } } + ] + } + ] + } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request" } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "rounded-lg bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 p-3 space-y-2 text-xs" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "font-medium text-gray-700 dark:text-gray-200 whitespace-nowrap" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_register" }, + { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_register_hint" } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request" }, + { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request_hint" } + ] + } + ] + } + ] + } + ] + }, + { + "id": "template_form_preview", + "type": "basic", + "name": "Div", + "props": { "className": "bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label mb-3 text-gray-700 dark:text-gray-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.preview_title" }, + { + "type": "basic", + "name": "Div", + "if": "{{(_local.templateForm?.templateContent ?? '') !== ''}}", + "props": { "className": "bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-600 p-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-gray-100 leading-relaxed min-h-40" }, + "text": "{{_local.templateForm?.templateContent ?? ''}}" + }, + { + "type": "basic", + "name": "Div", + "if": "{{(_local.templateForm?.templateContent ?? '') === ''}}", + "props": { "className": "bg-white dark:bg-gray-900 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-400 dark:text-gray-500 min-h-40 flex items-center justify-center text-center" }, + "text": "$t:sirsoft-message_bizppurio.templates.form.preview_empty" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint mt-3" }, + "text": "$t:sirsoft-message_bizppurio.templates.form.type_item_list_notice" + } + ] + } + ] + } + ] + } + ] + } + ] } ] - } + }, + "modals": [ + { + "id": "alimtalk_template_detail_modal", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.templates.detail.title", + "size": "lg" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "space-y-3" }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name" }, + { "type": "basic", "name": "P", "props": { "className": "text-body" }, "text": "{{_local.templateDetail?.templateName ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code" }, + { "type": "basic", "name": "P", "props": { "className": "font-mono text-sm text-body" }, "text": "{{_local.templateDetail?.templateCode ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content" }, + { "type": "basic", "name": "Div", "props": { "className": "bg-gray-50 dark:bg-gray-900/40 rounded p-3 whitespace-pre-wrap text-sm text-body" }, "text": "{{_local.templateDetail?.templateContent ?? ''}}" } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "flex justify-end pt-2" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary" }, + "actions": [ + { "type": "click", "handler": "closeModal", "target": "alimtalk_template_detail_modal" } + ], + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.detail.close" } + ] + } + ] + } + ] + } + ] + } + ] } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php new file mode 100644 index 00000000..c2353e4b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -0,0 +1,284 @@ +guard(function () use ($request) { + $result = $this->service->list([ + 'status' => $request->query('status'), + 'keyword' => $request->query('keyword'), + 'page' => $request->query('page'), + 'count' => $request->query('count'), + ]); + + return ResponseHelper::success('messages.success', $result); + }); + } + + /** + * 알림톡 템플릿 상세를 실시간 조회합니다. + * + * @param string $templateCode 템플릿 코드 + */ + public function show(string $templateCode): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'template' => $this->service->detail($templateCode), + ])); + } + + /** + * 템플릿 등록에 사용할 카테고리 전체를 조회합니다. + */ + public function categories(): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'categories' => $this->service->categories(), + ])); + } + + /** + * 발신프로필(사용중) 정보를 조회합니다. + */ + public function profiles(): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'profiles' => $this->service->senderProfiles(), + ])); + } + + /** + * 이미지형 템플릿용 이미지를 업로드하고 카카오 등록 URL 을 반환합니다. + * + * 업로드된 임시 파일을 카카오 이미지 업로드 API 로 전달하고, 반환된 URL 을 프론트가 + * templateImageUrl 로 사용한다. + * + * @param UploadTemplateImageRequest $request 이미지 파일 검증 Request + */ + public function uploadImage(UploadTemplateImageRequest $request): JsonResponse + { + return $this->guard(function () use ($request) { + $file = $request->file('image'); + + $url = $this->service->uploadImage( + $file->getRealPath(), + $file->getClientOriginalName(), + ); + + return ResponseHelper::success('messages.success', [ + 'image_url' => $url, + 'image_name' => $file->getClientOriginalName(), + ]); + }); + } + + /** + * 알림톡 템플릿을 신규 등록합니다. + * + * @param StoreAlimtalkTemplateRequest $request 유형별 검증 Request + */ + public function store(StoreAlimtalkTemplateRequest $request): JsonResponse + { + return $this->guard(function () use ($request) { + $data = $request->validated(); + $requestInspection = (bool) ($data['requestInspection'] ?? false); + + $template = $this->service->create($data); + + // [등록 후 검수요청]: 등록으로 받은 템플릿 코드로 즉시 검수를 요청한다. + if ($requestInspection) { + $templateCode = (string) ($template['templateCode'] ?? ''); + if ($templateCode !== '') { + $this->service->requestInspection($templateCode); + } + + return ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.created_requested', + ['template' => $template], + ); + } + + return ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.created', + ['template' => $template], + ); + }); + } + + /** + * 알림톡 템플릿을 수정합니다. (대기 + 검수 REG/REJ 상태만) + * + * @param UpdateAlimtalkTemplateRequest $request 유형별 검증 Request + * @param string $templateCode 기존 템플릿 코드 + */ + public function update(UpdateAlimtalkTemplateRequest $request, string $templateCode): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success( + 'sirsoft-message_bizppurio::messages.template.updated', + ['template' => $this->service->update($templateCode, $request->validated())], + )); + } + + /** + * 알림톡 템플릿을 삭제합니다. (대기 + 검수 REG/REJ 상태만) + * + * @param string $templateCode 템플릿 코드 + */ + public function destroy(string $templateCode): JsonResponse + { + return $this->guard(function () use ($templateCode) { + $this->service->delete($templateCode); + + return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.deleted'); + }); + } + + /** + * 검수를 요청합니다. (검수 REG 상태만) + * + * @param Request $request HTTP 요청(comment 선택) + * @param string $templateCode 템플릿 코드 + */ + public function requestInspection(Request $request, string $templateCode): JsonResponse + { + return $this->guard(function () use ($request, $templateCode) { + $this->service->requestInspection($templateCode, $request->input('comment')); + + return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.requested'); + }); + } + + /** + * 검수 요청을 취소합니다. (검수 REQ 상태만) + * + * @param string $templateCode 템플릿 코드 + */ + public function cancelRequest(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'cancelRequest', 'request_canceled'); + } + + /** + * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function stop(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'stop', 'stopped'); + } + + /** + * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function reuse(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'reuse', 'resumed'); + } + + /** + * 승인을 취소합니다(재검수 가능 상태로 복귀). (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function cancelApproval(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'cancelApproval', 'approval_canceled'); + } + + /** + * 휴면 템플릿을 해제합니다. (DMT 상태) + * + * @param string $templateCode 템플릿 코드 + */ + public function release(string $templateCode): JsonResponse + { + return $this->statusAction($templateCode, 'release', 'released'); + } + + /** + * senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리. + * + * @param string $templateCode 템플릿 코드 + * @param string $method 서비스 메서드명 + * @param string $messageKey 성공 메시지 키(messages.template.*) + */ + private function statusAction(string $templateCode, string $method, string $messageKey): JsonResponse + { + return $this->guard(function () use ($templateCode, $method, $messageKey) { + $this->service->{$method}($templateCode); + + return ResponseHelper::success("sirsoft-message_bizppurio::messages.template.{$messageKey}"); + }); + } + + /** + * kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환합니다. + * + * 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 반려/차단/상태오류 원인을 + * 즉시 파악하게 한다. errors 에 result_code 를 실어 프론트가 코드 분기 가능. + * + * @param callable(): JsonResponse $callback 실제 처리 + */ + private function guard(callable $callback): JsonResponse + { + try { + return $callback(); + } catch (BizppurioApiException $e) { + return ResponseHelper::error( + 'sirsoft-message_bizppurio::messages.error.kakao_request_failed', + 422, + [ + 'kakao_message' => $e->getMessage(), + 'result_code' => $e->getResultCode(), + ], + ); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php new file mode 100644 index 00000000..fe3e6b36 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php @@ -0,0 +1,109 @@ + + */ + public function rules(): array + { + return [ + // 공통 필수 + 'templateName' => ['required', 'string', 'max:200'], + 'templateContent' => ['required', 'string', 'max:1300'], + 'categoryCode' => ['required', 'string', 'max:20'], + 'templateEmphasizeType' => ['required', Rule::in(['NONE', 'IMAGE', 'TEXT'])], + + // 템플릿 코드(선택, 자동 생성 가능). 영문·숫자·언더바·하이픈, 최대 30자 + 'templateCode' => ['nullable', 'string', 'max:30', 'regex:/^[A-Za-z0-9_-]+$/'], + + // 강조표기형(TEXT) 조건부 필수 — 강조표기 문구(변수 가능) + 보조문구(변수 불가) + 'templateTitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:23'], + 'templateSubtitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:18'], + + // 이미지형(IMAGE) 조건부 필수 — 이미지 파일명 + 링크 + 'templateImageName' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'max:255'], + 'templateImageUrl' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'url', 'max:500'], + + // 선택 항목(§6-4): 부가정보 · 미리보기 메시지 · 템플릿 보안 + 'templatePreviewMessage' => ['nullable', 'string', 'max:40'], + 'templateExtra' => ['nullable', 'string', 'max:500'], + 'securityFlag' => ['nullable', 'boolean'], + + // 등록 후 즉시 검수요청 여부([등록 후 검수요청] 버튼) — 서비스가 소비, kapi payload 제외 + 'requestInspection' => ['nullable', 'boolean'], + + // 버튼(최대 5) + 'buttons' => ['nullable', 'array', 'max:5'], + 'buttons.*.name' => ['required_with:buttons', 'string', 'max:20'], + 'buttons.*.linkType' => ['required_with:buttons', 'string', 'max:4'], + 'buttons.*.linkMo' => ['nullable', 'string', 'max:500'], + 'buttons.*.linkPc' => ['nullable', 'string', 'max:500'], + 'buttons.*.linkAnd' => ['nullable', 'string', 'max:500'], + 'buttons.*.linkIos' => ['nullable', 'string', 'max:500'], + + // 바로연결(최대 10) + 'quickReplies' => ['nullable', 'array', 'max:10'], + 'quickReplies.*.name' => ['required_with:quickReplies', 'string', 'max:20'], + 'quickReplies.*.linkType' => ['required_with:quickReplies', 'string', 'max:4'], + + // 대표 링크(선택) — PC/모바일 웹, 앱 스킴 + 'templateRepresentLink' => ['nullable', 'array'], + 'templateRepresentLink.linkPc' => ['nullable', 'string', 'max:500'], + 'templateRepresentLink.linkMo' => ['nullable', 'string', 'max:500'], + 'templateRepresentLink.linkAnd' => ['nullable', 'string', 'max:500'], + 'templateRepresentLink.linkIos' => ['nullable', 'string', 'max:500'], + ]; + } + + /** + * 검증 속성 라벨(다국어). + * + * @return array + */ + public function attributes(): array + { + return [ + 'templateName' => __('sirsoft-message_bizppurio::messages.template.fields.name'), + 'templateContent' => __('sirsoft-message_bizppurio::messages.template.fields.content'), + 'categoryCode' => __('sirsoft-message_bizppurio::messages.template.fields.category'), + 'templateEmphasizeType' => __('sirsoft-message_bizppurio::messages.template.fields.emphasize_type'), + 'templateCode' => __('sirsoft-message_bizppurio::messages.template.fields.code'), + 'templateTitle' => __('sirsoft-message_bizppurio::messages.template.fields.title'), + 'templateSubtitle' => __('sirsoft-message_bizppurio::messages.template.fields.subtitle'), + 'templateImageName' => __('sirsoft-message_bizppurio::messages.template.fields.image_name'), + 'templateImageUrl' => __('sirsoft-message_bizppurio::messages.template.fields.image_url'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php new file mode 100644 index 00000000..0e6a0063 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php @@ -0,0 +1,16 @@ + + */ + public function rules(): array + { + return [ + // jpg/png, 최대 500KB (카카오 이미지형 제한) + 'image' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:500'], + ]; + } + + /** + * 검증 속성 라벨(다국어). + * + * @return array + */ + public function attributes(): array + { + return [ + 'image' => __('sirsoft-message_bizppurio::messages.template.fields.image'), + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php new file mode 100644 index 00000000..7a71e4f2 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php @@ -0,0 +1,581 @@ + lang key, 'variant' => 배지 색]. + * variant 는 프론트가 배지 색상 클래스로 사용한다(green/yellow/red/gray/dark/purple). + * + * @var array + */ + private const STATUS_BADGES = [ + 'RDY' => ['label_key' => 'sendable', 'variant' => 'green'], + 'ACT' => ['label_key' => 'sendable', 'variant' => 'green'], + 'REQ' => ['label_key' => 'inspecting', 'variant' => 'yellow'], + 'REJ' => ['label_key' => 'rejected', 'variant' => 'red'], + 'REG' => ['label_key' => 'uninspected', 'variant' => 'gray'], + 'STP' => ['label_key' => 'stopped', 'variant' => 'dark'], + 'BLK' => ['label_key' => 'blocked', 'variant' => 'dark'], + 'DMT' => ['label_key' => 'dormant', 'variant' => 'purple'], + ]; + + /** + * serviceStatus → 상태별 가능 액션(정본 표, §6-3). + * + * @var array> + */ + private const STATUS_ACTIONS = [ + 'REG' => ['edit', 'delete', 'request'], + 'REQ' => ['cancel_request'], + 'REJ' => ['edit', 'delete'], + 'RDY' => ['stop', 'cancel_approval'], + 'ACT' => ['stop', 'cancel_approval'], + 'STP' => ['reuse'], + 'DMT' => ['release'], + 'BLK' => [], + ]; + + /** + * @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트 + * @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key) + */ + public function __construct( + private readonly BizppurioKakaoApiClient $kakao, + private readonly PluginSettingsService $pluginSettings, + ) {} + + /** + * 알림톡 템플릿 목록을 실시간 조회합니다. + * + * @param array $filters status(templateStatus)·keyword·page·count + * @return array{templates: array>, pagination: array} + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function list(array $filters = []): array + { + $params = [ + 'count' => (int) ($filters['count'] ?? self::DEFAULT_COUNT), + 'page' => max(1, (int) ($filters['page'] ?? 1)), + ]; + + if (! empty($filters['status'])) { + $params['templateStatus'] = (string) $filters['status']; + } + + if (! empty($filters['keyword'])) { + $params['keyword'] = (string) $filters['keyword']; + } + + $response = $this->kakao->getTemplateList($this->senderKey(), $params); + + $this->assertSuccess($response); + + $rows = (array) ($response['data']['list'] ?? $response['data'] ?? []); + + return [ + 'templates' => array_map(fn (array $row) => $this->decorate($row), $rows), + 'pagination' => [ + 'total' => (int) ($response['totalCount'] ?? count($rows)), + 'total_page' => (int) ($response['totalPage'] ?? 1), + 'current_page' => (int) ($response['currentPage'] ?? $params['page']), + 'per_page' => $params['count'], + ], + ]; + } + + /** + * 알림톡 템플릿 상세를 실시간 조회합니다. + * + * @param string $templateCode 템플릿 코드 + * @return array 배지·가능 액션이 부가된 템플릿 상세 + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function detail(string $templateCode): array + { + $response = $this->kakao->getTemplateDetail($this->senderKey(), $templateCode); + + $this->assertSuccess($response); + + return $this->decorate((array) ($response['data'] ?? [])); + } + + /** + * 템플릿 등록에 사용할 카테고리 목록 전체를 조회합니다. + * + * @return array> [{code, name, groupName}] + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function categories(): array + { + $response = $this->kakao->request('/v3/kakao/template/category/all'); + + $this->assertSuccess($response); + + return array_values((array) ($response['data'] ?? [])); + } + + /** + * 발신프로필(사용중) 정보를 조회합니다. + * + * @return array 발신프로필 응답 data + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function senderProfiles(): array + { + $response = $this->kakao->getSenderProfiles(); + + $this->assertSuccess($response); + + return (array) ($response['data'] ?? []); + } + + /** + * 이미지형 템플릿용 이미지를 카카오 서버에 업로드하고 등록 URL 을 반환합니다. + * + * 업로드 성공 시 카카오가 반환한 이미지 URL 을 `templateImageUrl` 로 사용한다. + * + * @param string $filePath 임시 업로드 파일 절대 경로 + * @param string $fileName 원본 파일명 + * @return string 카카오 서버에 등록된 이미지 URL + * + * @throws BizppurioApiException 업로드 실패 시 + */ + public function uploadImage(string $filePath, string $fileName): string + { + $response = $this->kakao->uploadTemplateImage($filePath, $fileName); + + $this->assertSuccess($response); + + return (string) ($response['image'] ?? ''); + } + + /** + * 알림톡 템플릿을 신규 등록합니다. + * + * senderKey·templateMessageType 은 서비스가 채우며(FormRequest 는 화면 입력만 검증), + * messageType 은 부가정보/채널추가 버튼 유무로 자동 계산한다. + * + * @param array $data 검증된 템플릿 입력 + * @return array 등록 결과(템플릿 상세) + * + * @throws BizppurioApiException 등록 실패 시 + */ + public function create(array $data): array + { + $payload = $this->buildPayload($data); + + $response = $this->kakao->request('/v3/kakao/template/add', $payload); + + $this->assertSuccess($response); + + $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.create', [ + 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_create', + 'description_params' => ['template_name' => (string) ($data['templateName'] ?? '')], + ]); + + return (array) ($response['data'] ?? []); + } + + /** + * 알림톡 템플릿을 수정합니다. (대기(R) + 검수 REG/REJ 상태만 가능) + * + * @param string $templateCode 기존 템플릿 코드 + * @param array $data 검증된 템플릿 입력 + * @return array 수정 결과(템플릿 상세) + * + * @throws BizppurioApiException 수정 실패 시 + */ + public function update(string $templateCode, array $data): array + { + $payload = array_merge($this->buildPayload($data), [ + 'templateCode' => $templateCode, + ]); + + $response = $this->kakao->request('/v3/kakao/template/update', $payload); + + $this->assertSuccess($response); + + $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.update', [ + 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_update', + 'description_params' => ['template_code' => $templateCode], + ]); + + return (array) ($response['data'] ?? []); + } + + /** + * 알림톡 템플릿을 삭제합니다. (대기(R) + 검수 REG/REJ 상태만 가능) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 삭제 실패 시 + */ + public function delete(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/delete', $templateCode, 'delete'); + } + + /** + * 검수를 요청합니다. (대기(R) + 검수 REG 상태만 가능) + * + * @param string $templateCode 템플릿 코드 + * @param string|null $comment 의견/문의(최대 500자) + * + * @throws BizppurioApiException 요청 실패 시 + */ + public function requestInspection(string $templateCode, ?string $comment = null): void + { + $params = [ + 'senderKey' => $this->senderKey(), + 'templateCode' => $templateCode, + ]; + + if ($comment !== null && $comment !== '') { + $params['comment'] = $comment; + } + + $response = $this->kakao->request('/v3/kakao/template/request', $params); + + $this->assertSuccess($response); + + $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.request', [ + 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_request', + 'description_params' => ['template_code' => $templateCode], + ]); + } + + /** + * 검수 요청을 취소합니다. (검수 REQ 상태만 가능) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 취소 실패 시 + */ + public function cancelRequest(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/cancel_request', $templateCode, 'cancel_request'); + } + + /** + * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 중지 실패 시 + */ + public function stop(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/stop', $templateCode, 'stop'); + } + + /** + * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 해제 실패 시 + */ + public function reuse(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/reuse', $templateCode, 'reuse'); + } + + /** + * 승인을 취소합니다(재검수 요청 가능 상태로 복귀). (RDY/ACT 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 취소 실패 시 + */ + public function cancelApproval(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/cancel_approval', $templateCode, 'cancel_approval'); + } + + /** + * 휴면 템플릿을 해제합니다. (DMT 상태) + * + * @param string $templateCode 템플릿 코드 + * + * @throws BizppurioApiException 해제 실패 시 + */ + public function release(string $templateCode): void + { + $this->simpleAction('/v3/kakao/template/release', $templateCode, 'release'); + } + + /** + * senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리. + * + * @param string $path kapi 엔드포인트 경로 + * @param string $templateCode 템플릿 코드 + * @param string $action 활동 로그 action 세그먼트 + * + * @throws BizppurioApiException 요청 실패 시 + */ + private function simpleAction(string $path, string $templateCode, string $action): void + { + $response = $this->kakao->request($path, [ + 'senderKey' => $this->senderKey(), + 'templateCode' => $templateCode, + ]); + + $this->assertSuccess($response); + + $this->logActivity("sirsoft-message_bizppurio.alimtalk_template.{$action}", [ + 'description_key' => "sirsoft-message_bizppurio::activity_log.description.alimtalk_template_{$action}", + 'description_params' => ['template_code' => $templateCode], + ]); + } + + /** + * 화면 입력을 kapi 등록/수정 payload 로 조립합니다. + * + * - senderKey 는 환경설정에서 주입 + * - templateMessageType 은 부가정보·채널추가(AC) 버튼 유무로 자동 계산 + * - 빈 선택항목은 payload 에서 제외(kapi 유형별 필수 규칙 위반 방지) + * + * @param array $data 검증된 입력 + * @return array kapi payload (bizId/apiKey 제외 — 클라이언트가 주입) + */ + private function buildPayload(array $data): array + { + $payload = [ + 'senderKey' => $this->senderKey(), + 'senderKeyType' => 'S', + 'templateName' => (string) $data['templateName'], + 'templateContent' => (string) $data['templateContent'], + 'categoryCode' => (string) $data['categoryCode'], + 'templateEmphasizeType' => (string) ($data['templateEmphasizeType'] ?? 'NONE'), + ]; + + if (! empty($data['templateCode'])) { + $payload['templateCode'] = (string) $data['templateCode']; + } + + // 강조표기형(TEXT) + if ($payload['templateEmphasizeType'] === 'TEXT') { + $payload['templateTitle'] = (string) ($data['templateTitle'] ?? ''); + $payload['templateSubtitle'] = (string) ($data['templateSubtitle'] ?? ''); + } + + // 이미지형(IMAGE) + if ($payload['templateEmphasizeType'] === 'IMAGE') { + $payload['templateImageName'] = (string) ($data['templateImageName'] ?? ''); + $payload['templateImageUrl'] = (string) ($data['templateImageUrl'] ?? ''); + } + + // 선택항목(§6-4): 부가정보 · 미리보기 메시지 + foreach (['templatePreviewMessage', 'templateExtra'] as $optional) { + if (! empty($data[$optional])) { + $payload[$optional] = (string) $data[$optional]; + } + } + + if (array_key_exists('securityFlag', $data)) { + $payload['securityFlag'] = (bool) $data['securityFlag']; + } + + // 선택항목: 버튼(최대 5) · 바로연결(최대 10) + $buttons = $this->normalizeList($data['buttons'] ?? []); + if ($buttons !== []) { + $payload['buttons'] = $buttons; + } + + $quickReplies = $this->normalizeList($data['quickReplies'] ?? []); + if ($quickReplies !== []) { + $payload['quickReplies'] = $quickReplies; + } + + // 선택항목: 대표 링크(빈 링크 필드 제거 후 하나라도 있으면 포함) + $representLink = array_filter( + (array) ($data['templateRepresentLink'] ?? []), + static fn ($v) => is_string($v) && $v !== '', + ); + if ($representLink !== []) { + $payload['templateRepresentLink'] = $representLink; + } + + $payload['templateMessageType'] = $this->resolveMessageType($payload); + + return $payload; + } + + /** + * templateMessageType 을 부가정보·채널추가(AC) 버튼 유무로 계산합니다. + * + * - 부가정보(templateExtra) 있음 → EX + * - 채널추가(AC) 버튼 있음 → AD + * - 둘 다 → MI + * - 없음 → BA + * + * @param array $payload 조립 중인 payload + * @return string BA/EX/AD/MI + */ + private function resolveMessageType(array $payload): string + { + $hasExtra = ! empty($payload['templateExtra']); + $hasAddChannel = false; + + foreach ((array) ($payload['buttons'] ?? []) as $button) { + if (($button['linkType'] ?? null) === 'AC') { + $hasAddChannel = true; + break; + } + } + + return match (true) { + $hasExtra && $hasAddChannel => 'MI', + $hasExtra => 'EX', + $hasAddChannel => 'AD', + default => 'BA', + }; + } + + /** + * 버튼/바로연결 배열에서 빈 항목을 제거하고 정규화합니다. + * + * @param mixed $list 입력 배열 + * @return array> + */ + private function normalizeList(mixed $list): array + { + if (! is_array($list)) { + return []; + } + + return array_values(array_filter($list, static fn ($item) => is_array($item) && ! empty($item['name']) && ! empty($item['linkType']))); + } + + /** + * 템플릿 행에 상태 배지·가능 액션을 부가합니다. + * + * serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다. + * + * @param array $row kapi 템플릿 행 + * @return array 배지·액션이 부가된 행 + */ + private function decorate(array $row): array + { + $status = (string) ($row['serviceStatus'] ?? $this->deriveStatus($row)); + $badge = self::STATUS_BADGES[$status] ?? ['label_key' => 'unknown', 'variant' => 'gray']; + + $row['service_status'] = $status; + $row['status_badge'] = [ + 'label_key' => 'sirsoft-message_bizppurio::messages.template.status.'.$badge['label_key'], + 'variant' => $badge['variant'], + ]; + $row['available_actions'] = self::STATUS_ACTIONS[$status] ?? []; + + return $row; + } + + /** + * 상세 응답에서 serviceStatus 가 없을 때 status/inspectionStatus 로 상태를 추정합니다. + * + * 상세 조회는 serviceStatus 대신 status(S/A/R)+inspectionStatus(REG/REQ/REJ/APR)를 + * 내려주므로, 목록과 동일한 배지 체계로 환원한다. + * + * @param array $row kapi 템플릿 상세 행 + * @return string serviceStatus 코드 + */ + private function deriveStatus(array $row): string + { + $inspection = (string) ($row['inspectionStatus'] ?? ''); + $status = (string) ($row['status'] ?? ''); + $block = (bool) ($row['block'] ?? false); + $dormant = (bool) ($row['dormant'] ?? false); + + return match (true) { + $block => 'BLK', + $dormant => 'DMT', + $inspection === 'REQ' => 'REQ', + $inspection === 'REJ' => 'REJ', + $inspection === 'APR' && $status === 'S' => 'STP', + $inspection === 'APR' && $status === 'A' => 'ACT', + $inspection === 'APR' => 'RDY', + default => 'REG', + }; + } + + /** + * 환경설정에서 발신프로필 키(sender_key)를 조회합니다. + * + * @return string 발신프로필 키 + * + * @throws BizppurioApiException 미설정 시 + */ + private function senderKey(): string + { + $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? []; + $senderKey = (string) ($settings['sender_key'] ?? ''); + + if ($senderKey === '') { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.sender_key_missing'), + ); + } + + return $senderKey; + } + + /** + * kapi 응답이 성공(200)이 아니면 message 를 담아 예외를 던집니다. + * + * @param array $response kapi 응답 + * + * @throws BizppurioApiException 실패 코드 시 + */ + private function assertSuccess(array $response): void + { + if ($this->kakao->isSuccess($response)) { + return; + } + + $message = (string) ($response['message'] ?? ''); + $code = (string) ($response['code'] ?? ''); + + throw new BizppurioApiException( + $message !== '' + ? $message + : __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + resultCode: $code !== '' ? $code : null, + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php index 1404fcff..7edad0f8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -87,6 +87,48 @@ class BizppurioKakaoApiClient ]); } + /** + * 알림톡 이미지형 템플릿용 이미지를 업로드합니다. (`/v3/kakao/image/alimtalk/template`) + * + * multipart/form-data 로 이미지 파일을 전송하고, 성공 시 카카오 서버에 등록된 이미지 + * URL(`image`)을 반환받는다. 이미지 제한: jpg/png, 최대 500KB, 가로 500px 이상. + * + * @param string $filePath 업로드할 이미지 파일의 절대 경로 + * @param string $fileName 원본 파일명 + * @return array 응답 배열 (code/message/image) + * + * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 + */ + public function uploadTemplateImage(string $filePath, string $fileName): array + { + [$bizId, $apiKey] = $this->credentials(); + + $response = $this->http() + ->attach('image', file_get_contents($filePath), $fileName) + ->post(self::BASE_URL.'/v3/kakao/image/alimtalk/template', [ + 'bizId' => $bizId, + 'apiKey' => $apiKey, + ]); + + if ($response->failed()) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + httpStatus: $response->status(), + ); + } + + $result = $response->json(); + + if (! is_array($result)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.invalid_response'), + httpStatus: $response->status(), + ); + } + + return $result; + } + /** * 카카오 관리 API 의 임의 엔드포인트를 호출합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 6e34e5b6..e5897118 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Middleware\EnforceIdentityPolicy; use App\Http\Middleware\RefreshTokenExpiration; use Illuminate\Support\Facades\Route; +use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController; use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController; use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; @@ -45,4 +46,40 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g ]); })->middleware('permission:admin,core.plugins.read') ->name('report.url'); + + /* + |---------------------------------------------------------------------- + | 알림톡 템플릿 관리 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임 + |---------------------------------------------------------------------- + | + | 조회(list/detail/categories/profiles) = messaging.view + | 변경(store/update/destroy/검수/상태변경) = messaging.manage + | 템플릿은 DB 저장 없이 매 요청 실시간 조회. 설정 페이지 알림톡 템플릿 탭이 소비. + */ + Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () { + // 조회 (view) + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + Route::get('/', [AlimtalkTemplateController::class, 'index'])->name('index'); + Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories'); + Route::get('/profiles', [AlimtalkTemplateController::class, 'profiles'])->name('profiles'); + Route::get('/{templateCode}', [AlimtalkTemplateController::class, 'show'])->name('show'); + }); + + // 변경 (manage) + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { + Route::post('/', [AlimtalkTemplateController::class, 'store'])->name('store'); + // 이미지형 템플릿 이미지 업로드 (정적 경로 — {templateCode} 보다 먼저 선언) + Route::post('/image', [AlimtalkTemplateController::class, 'uploadImage'])->name('image'); + Route::put('/{templateCode}', [AlimtalkTemplateController::class, 'update'])->name('update'); + Route::delete('/{templateCode}', [AlimtalkTemplateController::class, 'destroy'])->name('destroy'); + + // 검수·상태 변경 + Route::post('/{templateCode}/request', [AlimtalkTemplateController::class, 'requestInspection'])->name('request'); + Route::post('/{templateCode}/cancel-request', [AlimtalkTemplateController::class, 'cancelRequest'])->name('cancel-request'); + Route::post('/{templateCode}/stop', [AlimtalkTemplateController::class, 'stop'])->name('stop'); + Route::post('/{templateCode}/reuse', [AlimtalkTemplateController::class, 'reuse'])->name('reuse'); + Route::post('/{templateCode}/cancel-approval', [AlimtalkTemplateController::class, 'cancelApproval'])->name('cancel-approval'); + Route::post('/{templateCode}/release', [AlimtalkTemplateController::class, 'release'])->name('release'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php new file mode 100644 index 00000000..ef196997 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php @@ -0,0 +1,301 @@ +app->instance(AlimtalkTemplateService::class, $mock); + + return $mock; + } + + /** + * 지정 권한을 가진 admin 사용자를 생성한다. + * + * @param array $permissionIdentifiers + */ + private function adminWith(array $permissionIdentifiers): User + { + $user = User::factory()->create(); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $testRole = Role::create([ + 'identifier' => 'bizppurio_tpl_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + + $permissionIds = []; + foreach ($permissionIdentifiers as $identifier) { + $permissionIds[] = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin'] + )->id; + } + $testRole->permissions()->sync($permissionIds); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * @param array $permissions + * @return array + */ + private function authHeaders(array $permissions): array + { + $user = $this->adminWith($permissions); + $token = $user->createToken('test-token')->plainTextToken; + + return [ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + ]; + } + + /** + * @scenario auth=view,service=ok + * + * @effects list_returns_templates_with_status_badge + */ + public function test_view권한으로_목록을_조회한다(): void + { + $this->mockService()->shouldReceive('list')->once()->andReturn([ + 'templates' => [ + ['templateCode' => 'TW_1', 'templateName' => '주문완료', 'status_badge' => ['variant' => 'green']], + ], + 'pagination' => ['total' => 1, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20], + ]); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE); + + $response->assertStatus(200); + $response->assertJsonPath('data.templates.0.templateCode', 'TW_1'); + $response->assertJsonPath('data.templates.0.status_badge.variant', 'green'); + } + + /** + * @scenario auth=guest + * + * @effects list_requires_authentication_returns_401 + */ + public function test_비인증은_401(): void + { + $this->getJson(self::BASE)->assertStatus(401); + } + + /** + * @scenario auth=view_only,action=store + * + * @effects store_requires_manage_permission_returns_403 + */ + public function test_view권한만으로_등록시_403(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + + $response->assertStatus(403); + } + + /** + * @scenario auth=manage,service=ok,type=NONE + * + * @effects store_creates_template_via_service + */ + public function test_manage권한으로_기본형_등록에_성공한다(): void + { + $this->mockService()->shouldReceive('create')->once()->andReturn(['templateCode' => 'NEW']); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => '가입환영', + 'templateContent' => '#{name}님 환영합니다', + 'categoryCode' => '001001', + 'templateEmphasizeType' => 'NONE', + ]); + + $response->assertStatus(200); + $response->assertJsonPath('data.template.templateCode', 'NEW'); + } + + /** + * @scenario auth=manage,service=ok,requestInspection=true + * + * @effects store_with_request_inspection_flag_registers_then_requests_inspection + */ + public function test_등록후검수요청_플래그로_등록과_검수요청을_이어서_수행한다(): void + { + $service = $this->mockService(); + $service->shouldReceive('create')->once()->andReturn(['templateCode' => 'TW_NEW']); + $service->shouldReceive('requestInspection')->once()->with('TW_NEW'); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => '가입환영', + 'templateContent' => '#{name}님 환영합니다', + 'categoryCode' => '001001', + 'templateEmphasizeType' => 'NONE', + 'requestInspection' => true, + ]); + + $response->assertStatus(200); + $response->assertJsonPath('data.template.templateCode', 'TW_NEW'); + } + + /** + * @scenario auth=manage,type=TEXT,missing=title + * + * @effects store_validates_text_type_requires_title_returns_422 + */ + public function test_강조표기형에_강조문구_누락시_422(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'TEXT', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['templateTitle', 'templateSubtitle']); + } + + /** + * @scenario auth=manage,type=IMAGE,missing=image + * + * @effects store_validates_image_type_requires_image_fields_returns_422 + */ + public function test_이미지형에_이미지필드_누락시_422(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'IMAGE', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['templateImageName', 'templateImageUrl']); + } + + /** + * @scenario auth=manage,service=throws + * + * @effects kapi_failure_is_surfaced_as_422_with_result_code + */ + public function test_kapi_실패는_422로_전파된다(): void + { + $this->mockService()->shouldReceive('create')->once() + ->andThrow(new BizppurioApiException('템플릿 불일치', resultCode: '7204')); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE, [ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + + $response->assertStatus(422); + $response->assertJsonPath('errors.result_code', '7204'); + $response->assertJsonPath('errors.kakao_message', '템플릿 불일치'); + } + + /** + * @scenario auth=manage,action=stop + * + * @effects stop_action_delegates_to_service_stop + */ + public function test_중지_상태변경에_성공한다(): void + { + $this->mockService()->shouldReceive('stop')->once()->with('TW_1'); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE.'/TW_1/stop'); + + $response->assertStatus(200); + } + + /** + * @scenario auth=manage,action=upload_image + * + * @effects image_upload_returns_kakao_url + */ + public function test_이미지_업로드가_카카오_ur_l을_반환한다(): void + { + $this->mockService()->shouldReceive('uploadImage')->once() + ->andReturn('https://mud-kage.kakao.com/dn/abc/banner.jpg'); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->post(self::BASE.'/image', [ + 'image' => UploadedFile::fake()->image('banner.jpg', 600, 400), + ], ['Accept' => 'application/json']); + + $response->assertStatus(200); + $response->assertJsonPath('data.image_url', 'https://mud-kage.kakao.com/dn/abc/banner.jpg'); + } + + /** + * @scenario auth=manage,action=upload_image,invalid=type + * + * @effects image_upload_rejects_non_image_returns_422 + */ + public function test_이미지가_아닌_파일_업로드는_422다(): void + { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->post(self::BASE.'/image', [ + 'image' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'), + ], ['Accept' => 'application/json']); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['image']); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php index 3798f8f0..ef9e92c9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -12,8 +12,8 @@ use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** * 비즈뿌리오 메시징 플러그인 설치 스모크 테스트. * - * plugin.php 가 선언하는 권한/메뉴/알림 정의/설정 스키마 구조와 - * activate/deactivate 의 메뉴 동기화·정리 동작을 검증한다. + * plugin.php 가 선언하는 권한/알림 정의/설정 스키마 구조와 + * 관리자 메뉴 미생성(⚑ 결정 3) 을 검증한다. */ class InstallationTest extends PluginTestCase { @@ -109,35 +109,28 @@ class InstallationTest extends PluginTestCase } } - public function test_activate_시_관리자_메뉴가_계층으로_생성된다(): void + /** + * 관리자 메뉴를 추가하지 않는다 (화면 배치 결정 2026-07-14, ⚑ 결정 3). + * + * 플러그인 메뉴 추가는 정식 기능이 아니므로 getAdminMenus()·메뉴 sync 를 + * 폐기했다. 진입은 코어 소유 설정 페이지(/admin/plugins/{id}/settings) 하나로 + * 통일한다. 활성화해도 플러그인 소속 메뉴 row 가 생성되지 않아야 한다. + */ + public function test_activate_시_관리자_메뉴를_생성하지_않는다(): void { $this->plugin->activate(); - $parent = Menu::where('slug', 'sirsoft-message_bizppurio') - ->where('extension_type', ExtensionOwnerType::Plugin->value) - ->first(); - - $this->assertNotNull($parent, '최상위 메뉴가 생성되어야 한다.'); - - $children = Menu::where('parent_id', $parent->id)->get(); - $this->assertCount(2, $children, '알림톡 템플릿 관리·발송 이력 2개 하위 메뉴가 생성되어야 한다.'); - - $childSlugs = $children->pluck('slug')->all(); - $this->assertContains('sirsoft-message_bizppurio-alimtalk-templates', $childSlugs); - $this->assertContains('sirsoft-message_bizppurio-dispatches', $childSlugs); - } - - public function test_deactivate_시_메뉴가_제거된다(): void - { - $this->plugin->activate(); - $this->assertGreaterThan(0, Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count()); - - $this->plugin->deactivate(); - $this->assertSame( 0, Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count(), - '비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.' + '메뉴 폐기 결정에 따라 플러그인 소속 메뉴가 생성되면 안 된다.' + ); + + $this->assertNull( + Menu::where('slug', 'sirsoft-message_bizppurio') + ->where('extension_type', ExtensionOwnerType::Plugin->value) + ->first(), + '최상위 메시지 발송 관리 메뉴가 남아 있으면 안 된다.' ); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php new file mode 100644 index 00000000..4ef4e27e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php @@ -0,0 +1,274 @@ + $settings + */ + private function service(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): AlimtalkTemplateService + { + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + + $kakao = new BizppurioKakaoApiClient($pluginSettings); + + return new AlimtalkTemplateService($kakao, $pluginSettings); + } + + public function test_목록은_상태_배지와_가능액션을_부가한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'totalCount' => 1, + 'totalPage' => 1, + 'currentPage' => 1, + 'data' => ['list' => [ + ['templateCode' => 'TW_1', 'templateName' => '주문완료', 'serviceStatus' => 'ACT'], + ]], + ], 200), + ]); + + $result = $this->service()->list(['status' => 'ACT']); + + $this->assertCount(1, $result['templates']); + $tpl = $result['templates'][0]; + $this->assertSame('ACT', $tpl['service_status']); + $this->assertSame('green', $tpl['status_badge']['variant']); + $this->assertEqualsCanonicalizing(['stop', 'cancel_approval'], $tpl['available_actions']); + $this->assertSame(1, $result['pagination']['total']); + } + + public function test_목록은_status_keyword를_kapi에_전달한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['list' => []]], 200)]); + + $this->service()->list(['status' => 'REQ', 'keyword' => '주문', 'page' => 2, 'count' => 10]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/list') + && $request['templateStatus'] === 'REQ' + && $request['keyword'] === '주문' + && $request['page'] === 2 + && $request['count'] === 10 + && $request['senderKey'] === 'SK_40'; + }); + } + + public function test_상세는_status_inspection으로_배지를_추론한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => [ + 'templateCode' => 'TW_2', + 'inspectionStatus' => 'APR', + 'status' => 'A', + 'block' => false, + 'dormant' => false, + ], + ], 200), + ]); + + $detail = $this->service()->detail('TW_2'); + + // inspection=APR + status=A → ACT(정상) + $this->assertSame('ACT', $detail['service_status']); + $this->assertSame('green', $detail['status_badge']['variant']); + } + + public function test_등록은_부가정보시_message_type_e_x로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'NEW']], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'templateExtra' => '부가정보', + ]); + + Http::assertSent(function ($request) { + return str_contains($request->url(), '/v3/kakao/template/add') + && $request['templateMessageType'] === 'EX' + && $request['templateExtra'] === '부가정보' + && $request['senderKey'] === 'SK_40'; + }); + } + + public function test_등록은_채널추가버튼시_message_type_a_d로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'buttons' => [['name' => '채널추가', 'linkType' => 'AC']], + ]); + + Http::assertSent(fn ($request) => $request['templateMessageType'] === 'AD'); + } + + public function test_등록은_부가정보와_채널추가_둘다면_m_i로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'templateExtra' => '부가', + 'buttons' => [['name' => '채널추가', 'linkType' => 'AC']], + ]); + + Http::assertSent(fn ($request) => $request['templateMessageType'] === 'MI'); + } + + public function test_등록은_옵션없으면_b_a로_계산한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + + Http::assertSent(fn ($request) => $request['templateMessageType'] === 'BA'); + } + + public function test_강조표기형은_title_subtitle을_전송한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'TEXT', + 'templateTitle' => '강조', + 'templateSubtitle' => '보조', + ]); + + Http::assertSent(fn ($request) => $request['templateEmphasizeType'] === 'TEXT' + && $request['templateTitle'] === '강조' + && $request['templateSubtitle'] === '보조'); + } + + public function test_버튼과_바로연결과_대표링크를_전송한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); + + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + 'buttons' => [['name' => '웹링크', 'linkType' => 'WL', 'linkMo' => 'https://m.example.com']], + 'quickReplies' => [['name' => '바로가기', 'linkType' => 'WL']], + 'templateRepresentLink' => ['linkPc' => 'https://example.com', 'linkMo' => ''], + ]); + + Http::assertSent(function ($request) { + return $request['buttons'][0]['linkType'] === 'WL' + && $request['quickReplies'][0]['name'] === '바로가기' + // 빈 링크 필드(linkMo='')는 제거되고 linkPc 만 포함 + && $request['templateRepresentLink'] === ['linkPc' => 'https://example.com']; + }); + } + + public function test_이미지_업로드는_카카오_이미지_ur_l을_반환한다(): void + { + Http::fake([ + 'kapi.ppurio.com/v3/kakao/image/*' => Http::response([ + 'code' => '200', + 'image' => 'https://mud-kage.kakao.com/dn/abc/image.jpg', + ], 200), + ]); + + $path = tempnam(sys_get_temp_dir(), 'img'); + file_put_contents($path, 'fake-image-bytes'); + + $url = $this->service()->uploadImage($path, 'banner.jpg'); + + @unlink($path); + + $this->assertSame('https://mud-kage.kakao.com/dn/abc/image.jpg', $url); + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/image/alimtalk/template')); + } + + public function test_검수요청은_템플릿코드와_comment를_전송한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->service()->requestInspection('TW_1', '검토 부탁드립니다'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/request') + && $request['templateCode'] === 'TW_1' + && $request['comment'] === '검토 부탁드립니다'); + } + + public function test_중지_상태변경은_stop_엔드포인트를_호출한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); + + $this->service()->stop('TW_1'); + + Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/stop') + && $request['templateCode'] === 'TW_1'); + } + + public function test_발신프로필_키_미설정시_예외(): void + { + $this->expectException(BizppurioApiException::class); + + $this->service(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => '']) + ->list(); + } + + public function test_kapi_실패코드시_예외에_결과코드가_담긴다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '템플릿 불일치'], 200), + ]); + + try { + $this->service()->create([ + 'templateName' => 'T', + 'templateContent' => '본문', + 'categoryCode' => '001', + 'templateEmphasizeType' => 'NONE', + ]); + $this->fail('예외가 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('7204', $e->getResultCode()); + $this->assertStringContainsString('템플릿 불일치', $e->getMessage()); + } + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php index 973b0dc1..347fa5c0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -89,6 +89,33 @@ class BizppurioKakaoApiClientTest extends PluginTestCase && $request['templateName'] === 'T'); } + public function test_이미지_업로드는_multipart로_전송하고_ur_l을_받는다(): void + { + Http::fake([ + 'kapi.ppurio.com/v3/kakao/image/*' => Http::response([ + 'code' => '200', + 'image' => 'https://mud-kage.kakao.com/dn/xyz/img.jpg', + ], 200), + ]); + + $path = tempnam(sys_get_temp_dir(), 'img'); + file_put_contents($path, 'bytes'); + + $result = $this->client()->uploadTemplateImage($path, 'banner.jpg'); + + @unlink($path); + + $this->assertSame('https://mud-kage.kakao.com/dn/xyz/img.jpg', $result['image']); + Http::assertSent(function ($request) { + // multipart 요청은 배열 접근($request['key'])이 불가하므로 URL·메서드·multipart 여부로 검증 + $bodyData = collect($request->data())->keyBy('name'); + + return str_contains($request->url(), '/v3/kakao/image/alimtalk/template') + && $request->isMultipart() + && ($bodyData['bizId']['contents'] ?? null) === 'biz01'; + }); + } + public function test_자격증명_미설정시_예외(): void { $client = new BizppurioKakaoApiClient( diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml new file mode 100644 index 00000000..524549dc --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml @@ -0,0 +1,104 @@ +# audit:allow test-scenario-coverage reason: | +# 알림톡 템플릿 관리 화면은 설정 페이지(plugin_settings.json)의 탭으로 배치되며(⚑ 결정 2·3, +# 독립 페이지·메뉴 없음), kapi 실시간 위임이라 DB 저장이 없다. axes cross product 의 UI 구조 +# (탭 전환·목록/폼 서브뷰·유형별 필드·변수삽입·상세 모달·i18n) effects 는 Vitest +# (alimtalk_templates.test.tsx) 정적 검증으로 가드된다. 권한 경계(view/manage)·유형별 +# 조건부 검증(422)·kapi 실패 전파(422)·상태변경 위임은 PHPUnit Feature +# (AlimtalkTemplateControllerTest) 로, messageType 자동계산·상태 배지/액션 매핑·kapi 파라미터 +# 전달은 Unit(AlimtalkTemplateServiceTest) 로 Http::fake 왕복 관찰한다. 런타임 Playwright spec 은 +# 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 카카오 관리 API 자격증명 필요) 단계에서 최초 +# 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. + +feature: 알림톡 템플릿 관리 (Phase 5) + +description: | + 설정 페이지 알림톡 템플릿 탭에서 카카오 관리 API(kapi.ppurio.com)로 알림톡 템플릿을 + 실시간 조회·등록·수정·삭제·검수·상태변경한다. DB 저장 없이 매 요청 kapi 위임이며, 저장은 + 하지 않는다(연동 binding 은 Phase 6). 진입은 코어 소유 설정 페이지 하나로 통일(메뉴 없음). + + 핵심 동작: + - 탭 네비게이션: 환경설정 ↔ 알림톡 템플릿 (activeSettingsTab, 배타 전환) + - 목록 서브뷰: 상태 필터(templateStatus) + 검색(keyword) + 상태 배지(serviceStatus) + 상세 + - 폼 서브뷰(목록↔폼 전환): 유형별 필드(NONE/IMAGE/TEXT, ITEM_LIST 제외) + 변수 클릭삽입 + - templateMessageType(BA/EX/AD/MI)은 부가정보·채널추가(AC) 버튼 유무로 백엔드 자동 계산 + - 권한: 조회=messaging.view / 변경=messaging.manage + - kapi 실패(반려·차단·상태오류)는 그대로 422 로 전파(운영자가 사유 즉시 확인) + - readiness: api_key·sender_key 미설정 시 안내 + [설정하기] 탭 이동 + +axes: + auth: [view, manage, guest] + emphasize_type: [NONE, IMAGE, TEXT] + message_type_driver: [none, extra, add_channel, both] + save_mode: [register, register_and_request] + kapi_result: [ok, fail] + +exclusions: + - { auth: guest, emphasize_type: IMAGE, reason: "비인증은 유형 무관 401 — 유형 axis 무의미" } + - { auth: view, kapi_result: fail, reason: "view 는 변경 액션 불가(403) — kapi 도달 전 차단" } + +effects: + # 탭 네비게이션 (UI) + - tab_navigation_has_connection_and_templates_tabs + - templates_tab_updates_url_query_and_refetches_list + - tab_panels_toggle_exclusively_by_query_tab + - templates_tab_persists_across_reload_via_url_query + - init_actions_loads_list_when_entering_templates_tab + # 데이터소스 (UI) + - templates_and_categories_datasources_target_admin_api + # 목록 서브뷰 (UI) + - list_view_shown_when_template_view_is_list + - toolbar_has_status_filter_and_keyword_search + - table_card_always_shows_header_and_branches_tbody_by_count + - table_iterates_templates_with_status_badge + - new_template_button_switches_to_form_and_resets_form + # 폼 서브뷰 (UI, 계획서 §6-4) + - form_view_shown_when_template_view_is_form + - common_fields_name_and_content_use_manual_value_binding + - template_code_field_auto_or_manual_toggle + - category_two_level_dropdown_from_category_all + - text_type_fields_shown_when_emphasize_type_is_text + - image_type_file_upload_shown_when_emphasize_type_is_image + - type_buttons_expose_only_none_image_text_not_item_list + - variable_buttons_insert_into_content_state_via_insert_variable_handler + - variable_reference_table_always_visible_inline + - optional_six_items_checkbox_expand_extra_preview_button_quickreply_link_security + - optional_no_header_field + - preview_reflects_content_state + - save_split_into_register_and_register_then_request_inspection_with_help_box + # 상세 모달 & readiness (UI) + - detail_modal_defined + - readiness_notice_shown_when_api_key_or_sender_key_missing + # 권한 경계 (백엔드) + - list_requires_view_permission + - guest_list_returns_401 + - store_requires_manage_permission_returns_403_for_view_only + - manage_can_create_basic_template + - register_then_request_inspection_flag_creates_then_requests + # 유형별 검증 (백엔드) + - text_type_missing_title_subtitle_returns_422 + - image_type_missing_image_fields_returns_422 + # messageType 자동계산 (백엔드) + - message_type_none_computes_ba + - message_type_extra_computes_ex + - message_type_add_channel_computes_ad + - message_type_both_computes_mi + # 상태 배지/액션 & kapi 전달 (백엔드) + - service_status_maps_to_badge_and_available_actions + - detail_derives_status_from_inspection_and_status + - list_passes_status_keyword_to_kapi + - text_type_sends_title_and_subtitle + - inspection_request_sends_code_and_comment + - stop_action_calls_kapi_stop_endpoint + # kapi 실패 전파 (백엔드) + - kapi_failure_surfaced_as_422_with_result_code + - sender_key_missing_throws + # 다국어 + - all_referenced_i18n_keys_exist_in_ko_and_en + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php + +audit: + status: codified \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml index 047d746d..02ae94e0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -24,7 +24,8 @@ description: | - 리포트 수신 설정: 발송 결과 수신 주소를 사이트 도메인 기준 절대 URL 로 조회·복사 - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT - - 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드 + - 설치·활성화 시 권한 2개(view/manage)·잔액부족 알림 정의 생성 + 설정 초기값 로드 + (관리자 메뉴는 추가하지 않음 — ⚑ 결정 3, 진입은 코어 소유 설정 페이지 하나) ※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관. @@ -65,8 +66,7 @@ effects: - all_referenced_i18n_keys_exist_in_ko_and_en # 설치·활성화 (백엔드) - install_creates_view_and_manage_permissions - - activate_creates_hierarchical_admin_menus - - deactivate_removes_plugin_menus + - activate_does_not_create_admin_menus # ⚑ 결정 3: 플러그인 메뉴 폐기, 진입은 설정 페이지 하나로 통일 - balance_low_notification_targets_admin - settings_defaults_load_with_test_mode_on diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts new file mode 100644 index 00000000..e9023699 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +/** + * 비즈뿌리오 메시징 플러그인 프론트엔드 에셋 빌드 설정. + * + * 커스텀 핸들러(insertVariable) 를 IIFE 번들로 빌드해 플러그인 활성화 시 로드한다. + * 출력물은 dist/js/plugin.iife.js 이며 plugin.json assets.js.output 과 일치한다. + */ +export default defineConfig({ + define: { + 'process.env.NODE_ENV': JSON.stringify('production'), + }, + + build: { + lib: { + entry: path.resolve(__dirname, 'resources/js/index.ts'), + name: 'SirsoftMessageBizppurio', + fileName: 'plugin', + formats: ['iife'], + }, + outDir: 'dist', + emptyOutDir: true, + sourcemap: true, + rollupOptions: { + output: { + entryFileNames: 'js/plugin.iife.js', + chunkFileNames: 'js/[name]-[hash].js', + }, + }, + minify: 'esbuild', + target: 'es2020', + chunkSizeWarningLimit: 500, + }, + + resolve: { + alias: { + '@': path.resolve(__dirname, 'resources/js'), + }, + }, +}); From af43a7c41d3cba1733662dfb025c41eb89f1c3d3 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 15 Jul 2026 17:50:06 +0900 Subject: [PATCH 07/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=97=B0=EB=8F=99=20=ED=8E=B8=EC=A7=91?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=A0=84=EC=9A=A9=EC=B9=B8=20=EC=A6=89?= =?UTF-8?q?=EC=8B=9C=EC=A0=80=EC=9E=A5=20+=20=EC=8B=9C=EB=94=A9=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=9B=85=20template=20=EC=A6=9D=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 재설계 — 코어 알림 설정 화면을 그대로 쓰고 알림톡 편집 시 전용 칸만 얹는다. - 시딩 필터 훅(seed.*.notifications.translations) 편승으로 회원 알림에 sms·alimtalk template 증강 (코어·모듈 무수정, cleanupStaleTemplates 에도 유지) - 코어 편집 모달에 overlay 로 전용 칸 append(연결 템플릿·SMS 대체) — 변경 즉시 저장, 코어 저장 버튼과 무관 (코어 템플릿 무오염) - 알림톡·SMS 탭 상태 배너(설정 미완료/테스트 모드) - 1차 구현(우리 목록 통째 교체·게이트·우리 편집 모달) 폐기 전 계층 테스트 통과 (백엔드 170 / 프론트 63), audit 위반 0. --- docs/backend/api/README.md | 4 +- .../docs/api/README.md | 3 +- .../docs/api/alimtalk-templates.md | 41 -- .../docs/api/notification-bindings.md | 197 +++++++++ .../lang/en/messages.php | 6 + .../lang/ko/messages.php | 6 + .../sirsoft-message_bizppurio/plugin.php | 40 +- .../extensions/notification_tab_core.json | 373 ++++++++++++++++++ .../layouts/notification_tab_binding.test.tsx | 142 +++++++ .../resources/lang/en.json | 16 + .../resources/lang/ko.json | 16 + .../src/Concerns/GuardsKakaoRequests.php | 42 ++ .../Admin/AlimtalkTemplateController.php | 36 +- .../Admin/NotificationBindingController.php | 95 +++++ .../StoreNotificationBindingRequest.php | 39 ++ .../RegisterNotificationChannelsListener.php | 24 +- .../SeedChannelTemplatesListener.php | 260 ++++++++++++ .../MessageBizppurioServiceProvider.php | 22 +- .../src/Services/AlimtalkChannelDriver.php | 217 ++++++++++ .../Services/NotificationBindingService.php | 151 +++++++ .../src/routes/api.php | 24 ++ .../NotificationBindingEndpointTest.php | 191 +++++++++ ...gisterNotificationChannelsListenerTest.php | 36 +- .../SeedChannelTemplatesListenerTest.php | 149 +++++++ .../Services/AlimtalkChannelDriverTest.php | 306 ++++++++++++++ .../NotificationBindingServiceTest.php | 144 +++++++ .../scenarios/alimtalk-dispatch-binding.yaml | 101 +++++ 27 files changed, 2596 insertions(+), 85 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index db6fff1f..504fdee8 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 388 +- **확장 수**: 10 · **엔드포인트 수**: 391 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,7 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 3 / 16 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 4 / 19 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index 3b65cd51..cf935df5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,11 +4,12 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 3 · **엔드포인트 수**: 16 +- **문서 수**: 4 · **엔드포인트 수**: 19 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | | [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 | +| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 | | [report.md](report.md) | `report` | 1 | | [webhook.md](webhook.md) | `webhook` | 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md index d79b3de5..a1b2e664 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -16,43 +16,6 @@ --- -## 개요 - -설정 페이지 알림톡 템플릿 탭에서 사용하는 관리 API 입니다. 카카오 관리 API(`kapi.ppurio.com`)에 -실시간 위임하며 템플릿을 DB 에 저장하지 않습니다. 조회 계열은 `messaging.view`, 변경 계열(등록· -수정·삭제·검수·상태변경·이미지 업로드)은 `messaging.manage` 권한을 요구합니다. kapi 실패(반려·차단· -상태오류)는 `422` 로 전파되며 `errors.result_code`(kapi 결과코드)·`errors.kakao_message`(카카오 사유)를 -함께 내려줍니다. - -**주요 요청 파라미터(등록·수정 `POST /` · `PUT /{code}`)** - -| 파라미터 | 타입 | 필수 | 설명 | -| --- | --- | :---: | --- | -| `templateName` | string | ✓ | 템플릿명(관리용, 최대 200자) | -| `templateContent` | string | ✓ | 본문(최대 1300자, `#{변수}` 사용) | -| `categoryCode` | string | ✓ | 카테고리 코드(카테고리 조회에서 획득) | -| `templateEmphasizeType` | string | ✓ | 유형: `NONE`/`IMAGE`/`TEXT`(1차 지원, ITEM_LIST 제외) | -| `templateCode` | string | | 템플릿 코드. 비우면 카카오가 **자동 생성** | -| `templateTitle`/`templateSubtitle` | string | △ | 강조표기형(TEXT) 필수 | -| `templateImageUrl`/`templateImageName` | string | △ | 이미지형(IMAGE) 필수 — 이미지 업로드 API 로 획득한 URL | -| `templateExtra`·`templatePreviewMessage`·`securityFlag` | mixed | | 선택: 부가정보·미리보기 메시지·보안 | -| `buttons[]`(최대5)·`quickReplies[]`(최대10)·`templateRepresentLink` | array | | 선택: 버튼·바로연결·대표 링크 | -| `requestInspection` | boolean | | true 면 등록 직후 검수요청까지 수행([등록 후 검수요청]) | - -> `senderKey`·`templateMessageType`(BA/EX/AD/MI)은 클라이언트가 보내지 않습니다. `senderKey` 는 -> 환경설정(발신프로필 키), `templateMessageType` 은 부가정보·채널추가(AC) 버튼 유무로 서버가 자동 계산합니다. - -**이미지 업로드(`POST /image`, multipart)**: 이미지형 템플릿용 이미지를 카카오 서버에 올려 URL 을 -반환합니다(`data.image_url`). 제한: jpg/png, 최대 500KB, 가로 500px 이상. - -상태별 가능 액션(정본): REG→수정/삭제/검수요청 · REQ→검수취소 · REJ→수정/삭제 · -RDY·ACT→중지/승인취소 · STP→중지해제 · DMT→휴면해제 · BLK→(없음). - -아래 엔드포인트별 상세는 `php artisan api:docgen` 이 생성한 스캐폴딩입니다. 실측이 인증을 요구해 -제외된 응답 필드/예시는 실연동 검증 단계에서 사람이 보강합니다. - ---- - ### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates @@ -114,7 +77,6 @@ Authorization: Bearer {YOUR_TOKEN} | templateImageUrl | body | string | 아니오 | max 500 | | | templatePreviewMessage | body | string | 아니오 | max 40 | | | templateExtra | body | string | 아니오 | max 500 | | -| templateHeader | body | string | 아니오 | max 16 | | | securityFlag | body | boolean | 아니오 | — | | | requestInspection | body | boolean | 아니오 | — | | | buttons | body | array | 아니오 | max 5 | | @@ -142,7 +104,6 @@ Content-Type: application/json "templateImageUrl": "https://example.com", "templatePreviewMessage": "예시값", "templateExtra": "예시값", - "templateHeader": "예시값", "securityFlag": true, "requestInspection": true, "buttons": [ @@ -411,7 +372,6 @@ Authorization: Bearer {YOUR_TOKEN} | templateImageUrl | body | string | 아니오 | max 500 | | | templatePreviewMessage | body | string | 아니오 | max 40 | | | templateExtra | body | string | 아니오 | max 500 | | -| templateHeader | body | string | 아니오 | max 16 | | | securityFlag | body | boolean | 아니오 | — | | | requestInspection | body | boolean | 아니오 | — | | | buttons | body | array | 아니오 | max 5 | | @@ -439,7 +399,6 @@ Content-Type: application/json "templateImageUrl": "https://example.com", "templatePreviewMessage": "예시값", "templateExtra": "예시값", - "templateHeader": "예시값", "securityFlag": true, "requestInspection": true, "buttons": [ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md new file mode 100644 index 00000000..f294271e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -0,0 +1,197 @@ +# Notification Bindings API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +> **용도**: 코어 알림 편집 모달(alimtalk 채널)에 얹힌 전용 칸이 소비하는 조회·저장 API. 연결 템플릿 드롭다운/SMS 대체 토글을 바꾸면 즉시 `store` 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관, 코어 템플릿 무오염). 저장은 "빈 코드=해제" 규칙을 따른다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Notification Bindings 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| bindings | array | `[]` | | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "bindings": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +알림 설정 화면의 알림톡 탭이 소비하는 목록이다. 코어 알림 정의 중 채널에 `alimtalk` 을 포함하는 활성 알림 전체를, 연결된 알림톡 템플릿(binding)과 조인해 반환한다. 미연결 알림은 `is_bound=false` 이고 `template_code`/`template_name` 은 null, `fallback_sms_enabled` 는 false 다. `variables` 는 코어 알림 정의의 변수 목록으로, 편집 모달의 "제공 변수" 안내에 쓰인다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification_type | body | string | 예 | max 100 | | +| template_code | body | string | 아니오 | max 30 | | +| template_name | body | string | 아니오 | max 255 | template 이름 (식별자) | +| fallback_sms_enabled | body | boolean | 아니오 | — | | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "notification_type": "예시값", + "template_code": "예시값", + "template_name": "예시 이름", + "fallback_sms_enabled": true +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| bindings | object | `{"실측 예시값":{"notification_type":"실측 예시값","template_code":"…` | | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.binding.saved", + "data": { + "bindings": { + "실측 예시값": { + "notification_type": "실측 예시값", + "template_code": "실측 예시값", + "template_name": "실측 예시값", + "fallback_sms_enabled": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 승인되지 않은 템플릿 코드도 저장 자체는 가능하나(운영자 선택 자유), 편집 모달 드롭다운은 승인(RDY/ACT) 템플릿만 노출해 실수를 방지한다. + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@approvedTemplates` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +연동 편집 모달의 "연결 템플릿" 드롭다운을 채우는 소스다. 카카오 템플릿 목록 중 serviceStatus 가 RDY(발송전)·ACT(정상)인 승인 템플릿만 반환한다. 자격증명(bizId·apiKey·senderKey) 미설정이거나 kapi 조회 실패 시 422 로 카카오 사유를 그대로 전달한다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index ee4a74aa..e64e04d4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -124,4 +124,10 @@ return [ 'image_url' => 'Image URL', ], ], + + // Notification-to-alimtalk template binding (NotificationBindingController responses) + 'binding' => [ + 'saved' => 'Alimtalk binding saved.', + 'removed' => 'Alimtalk binding removed.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index aa2079a6..bc1bcdf6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -124,4 +124,10 @@ return [ 'image_url' => '이미지 링크', ], ], + + // 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답) + 'binding' => [ + 'saved' => '알림톡 연동을 저장했습니다.', + 'removed' => '알림톡 연동을 해제했습니다.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 762108a1..492b4b0d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -5,8 +5,12 @@ namespace Plugins\Sirsoft\MessageBizppurio; use App\Enums\ExtensionOwnerType; use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; +use App\Extension\ModuleManager; +use Database\Seeders\NotificationDefinitionSeeder; +use Illuminate\Support\Facades\Log; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; +use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener; /** @@ -33,6 +37,34 @@ class Plugin extends AbstractPlugin ]; } + /** + * 플러그인 활성화 — 기존 회원 알림에 sms·alimtalk template 을 즉시 증강. + * + * SeedChannelTemplatesListener 는 코어/모듈이 알림 정의를 *시딩할 때* 필터 훅으로 증강한다. + * 그러나 플러그인 활성화 시점에는 코어/모듈 정의가 이미 시딩돼 있어(우리 채널 없이) 다음 + * 재시딩까지 alimtalk template 이 생기지 않는다. 따라서 활성화 시 코어·활성 모듈의 알림 + * 정의를 재시드해, 이제 활성화된 우리 필터를 통과시켜 기존 정의에도 채널을 즉시 반영한다. + * + * 재시드는 모두 user_overrides 보존 upsert(멱등)이며 코어·모듈 파일을 수정하지 않는다. + * 실패는 로그만 남기고 활성화 자체는 막지 않는다(발송·연동은 채널 등록만으로도 동작하며, + * template 은 다음 정상 재시딩에도 수렴). + * + * @return bool 활성화 성공 여부 + */ + public function activate(): bool + { + try { + app(NotificationDefinitionSeeder::class)->run(); + app(ModuleManager::class)->resyncAllActiveDeclarativeArtifacts(); + } catch (\Throwable $e) { + Log::warning('[sirsoft-message_bizppurio] 알림 채널 template 증강 재시드 실패', [ + 'error' => $e->getMessage(), + ]); + } + + return true; + } + /** * 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리. * @@ -294,9 +326,10 @@ class Plugin extends AbstractPlugin /** * 훅 리스너 목록 반환 * - * Phase 3: 채널 등록/readiness/3영역 노출(RegisterNotificationChannelsListener) + - * 비회원 주문 전화번호 주입(GuestPhoneExtractListener). - * Phase 4~6 에서 webhook/알림톡 발송 리스너를 추가한다. + * - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3) + * - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅) + * - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3) + * - ValidateBizppurioSettingsListener: 환경설정 검증 * * @return array */ @@ -304,6 +337,7 @@ class Plugin extends AbstractPlugin { return [ RegisterNotificationChannelsListener::class, + SeedChannelTemplatesListener::class, GuestPhoneExtractListener::class, ValidateBizppurioSettingsListener::class, ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json new file mode 100644 index 00000000..c8ced647 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -0,0 +1,373 @@ +{ + "target_layout": "admin_settings", + "comment": "알림 템플릿 편집 모달(alimtalk 채널)에 비즈뿌리오 전용 칸을 append 한다(계획서 §6-2, Phase 6). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다(코어 템플릿 무오염). channel==='alimtalk'일 때만 ①안내 카드 ②연결 템플릿 드롭다운 ③SMS 대체 토글을 노출하며, 값을 바꾸면 즉시 우리 API 로 저장하고 toast 로 피드백한다(별도 저장 버튼 없음 — PO 확정 UX). 코어 [저장] 버튼은 본문·수신자만 저장하고 연동과 무관하다. 초기값·저장 후 상태는 bizppurioBindings 데이터소스에서 편집 대상 알림 type 으로 읽는다.", + "data_sources": [ + { + "id": "bizppurioBindings", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "fallback": { + "data": { + "bindings": {} + } + } + }, + { + "id": "bizppurioApprovedTemplates", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "fallback": { + "data": { + "templates": [] + } + } + } + ], + "injections": [ + { + "target_id": "notif_channel_content", + "position": "prepend_child", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음, PO Q1-A). readiness 미충족 🔴 설정하기 안내 / is_test_mode 🟡 테스트 모드 안내. 코어 목록·모달은 그대로.", + "components": [ + { + "id": "bizppurio_status_banner", + "type": "basic", + "name": "Div", + "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", + "props": { + "className": "mb-3" + }, + "children": [ + { + "id": "bizppurio_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "text": "$t:sirsoft-message_bizppurio.banner.setup_action", + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ] + } + ] + }, + { + "id": "bizppurio_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready !== false && (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } + ] + } + ] + } + ] + }, + { + "target_id": "template_variables_info", + "position": "append", + "components": [ + { + "id": "bizppurio_alimtalk_section", + "comment": "알림톡 전용 칸 — channel==='alimtalk' 일 때만. 변경 즉시 우리 API 로 저장.", + "type": "basic", + "name": "Div", + "if": "{{_global.notification_template_form_modal?.template?.channel === 'alimtalk'}}", + "props": { + "className": "bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-4 space-y-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-start gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H4", + "props": { + "className": "text-sm font-medium text-teal-800 dark:text-teal-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300 mt-0.5" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + } + ] + } + ] + }, + { + "id": "bizppurio_binding_template_select", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.connected_template" + }, + { + "type": "composite", + "name": "Select", + "props": { + "value": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", + "className": "w-full text-sm", + "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "notification_template_form_modal.bizppurio.template_code": "{{$event.target.value}}", + "notification_template_form_modal.bizppurio.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "notification_template_form_modal.bizppurio.fallback_sms": "{{$event.target.value === '' ? false : (_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", + "template_code": "{{$event.target.value}}", + "template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0}}", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" + } + ] + }, + { + "id": "bizppurio_binding_fallback_toggle", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Toggle", + "props": { + "checked": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false)}}", + "disabled": "{{(_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')) === ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "notification_template_form_modal.bizppurio.fallback_sms": "{{!(_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", + "template_code": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", + "template_name": "{{_global.notification_template_form_modal?.bizppurio?.template_name ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_name ?? '')}}", + "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-secondary text-xs" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + } + ] + } + ] + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx new file mode 100644 index 00000000..583baf7f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -0,0 +1,142 @@ +/** + * 알림 설정 알림톡 연동 overlay 구조 검증 (Phase 6 재설계, §6-2) + * + * notification_tab_core.json 은 코어 admin_settings 편집 모달(alimtalk 채널)에 전용 칸을 얹고, + * 알림톡·SMS 탭 상단에 상태 배너를 얹는다. 코어 편집 모달·저장 버튼은 건드리지 않으며(무오염), + * 전용 칸 값을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음). + * + * 검증: + * - 데이터소스(bizppurioBindings·bizppurioApprovedTemplates) 주입 + * - 전용 칸: 안내 카드 · 연결 템플릿 드롭다운(즉시 저장) · SMS 대체 토글(연결 없으면 비활성) + * - 상태 배너: readiness 미충족(설정하기) · 테스트 모드 + * - 코어 편집 모달 저장 body 에 bizppurio 필드가 없다(무오염) + * - i18n 키 정합(ko·en 동일) + */ + +import { describe, it, expect } from 'vitest'; +import overlay from '../../../extensions/notification_tab_core.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { findById, type AnyNode } from './helpers'; + +/** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */ +const collectPluginKeys = (json: unknown): string[] => { + const text = JSON.stringify(json); + const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; + const keys = [ + ...prefixed.map((m) => m.replace('$t:', '')), + ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), + ]; + return Array.from(new Set(keys)); +}; + +type Overlay = { + target_layout: string; + data_sources?: Array<{ id: string; endpoint: string }>; + injections: Array<{ target_id: string; position: string; components: AnyNode[] }>; +}; + +const ext = overlay as unknown as Overlay; + +/** injection components 를 하나의 검색 가능한 루트로 감싼다. */ +const injectionRoot = (targetId: string): AnyNode => { + const inj = ext.injections.find((i) => i.target_id === targetId); + return { children: inj?.components ?? [] } as AnyNode; +}; + +describe('notification binding overlay — 대상·데이터소스', () => { + it('코어 admin_settings 레이아웃을 대상으로 한다', () => { + expect(ext.target_layout).toBe('admin_settings'); + }); + + it('연동 맵·승인 템플릿 데이터소스를 주입한다', () => { + const ids = (ext.data_sources ?? []).map((d) => d.id); + expect(ids).toContain('bizppurioBindings'); + expect(ids).toContain('bizppurioApprovedTemplates'); + }); + + it('전용 칸은 코어 편집 모달의 template_variables_info 뒤에 append 된다', () => { + const inj = ext.injections.find((i) => i.target_id === 'template_variables_info'); + expect(inj).toBeTruthy(); + expect(inj?.position).toBe('append'); + }); +}); + +describe('notification binding overlay — 전용 칸', () => { + const root = injectionRoot('template_variables_info'); + + it('알림톡 전용 섹션은 channel === alimtalk 일 때만 노출된다', () => { + const section = findById(root, 'bizppurio_alimtalk_section'); + expect(section).toBeTruthy(); + expect((section as { if?: string }).if).toContain("=== 'alimtalk'"); + }); + + it('연결 템플릿 드롭다운을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음)', () => { + const raw = JSON.stringify(findById(root, 'bizppurio_binding_template_select')); + expect(raw).toContain('apiCall'); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('binding.saved'); + }); + + it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이고, 변경 시 즉시 저장한다', () => { + const toggleWrap = findById(root, 'bizppurio_binding_fallback_toggle'); + const raw = JSON.stringify(toggleWrap); + expect(raw).toContain('"disabled"'); + expect(raw).toContain("=== ''"); + expect(raw).toContain('apiCall'); + expect(raw).toContain('"method":"POST"'); + }); + + it('저장 실패 시 에러 토스트를 띄운다', () => { + const raw = JSON.stringify(root); + expect(raw).toContain('binding.save_error'); + }); +}); + +describe('notification binding overlay — 상태 배너', () => { + const root = injectionRoot('notif_channel_content'); + + it('배너는 sms·alimtalk 탭에서 문제가 있을 때만 노출된다', () => { + const banner = findById(root, 'bizppurio_status_banner'); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).toContain("'sms'"); + expect(cond).toContain("'alimtalk'"); + expect(cond).toContain('readiness?.ready === false'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { + const raw = JSON.stringify(findById(root, 'bizppurio_banner_not_ready')); + expect(raw).toContain('banner.not_ready'); + expect(raw).toContain('banner.setup_action'); + expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); + }); + + it('테스트 모드 배너는 readiness 충족 + is_test_mode 일 때만 노출된다', () => { + const testBanner = findById(root, 'bizppurio_banner_test_mode'); + const cond = (testBanner as { if?: string }).if ?? ''; + expect(cond).toContain('readiness?.ready !== false'); + expect(cond).toContain('is_test_mode === true'); + expect(JSON.stringify(testBanner)).toContain('banner.test_mode'); + }); +}); + +describe('notification binding overlay — i18n 정합', () => { + it('overlay 가 참조하는 모든 키가 ko·en 에 존재한다', () => { + const keys = collectPluginKeys(overlay); + expect(keys.length).toBeGreaterThan(0); + + // 플러그인 resources/lang/{ko,en}.json 파일 루트가 곧 sirsoft-message_bizppurio 네임스페이스. + // 키 `sirsoft-message_bizppurio.binding.saved` → 첫 세그먼트(네임스페이스) 제거 후 파일 루트에서 해석. + const resolve = (root: unknown, path: string): unknown => + path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); + + for (const key of keys) { + expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); + expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); + } + }); +}); \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index a13ba160..d6a7f6e9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -62,6 +62,22 @@ "templates": "Alimtalk Templates" } }, + "binding": { + "section_title": "KakaoTalk Alimtalk Binding", + "section_hint": "Changes are saved instantly, separately from the message body.", + "connected_template": "Connected Template", + "none": "Not connected", + "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", + "fallback_sms": "Fall back to SMS on failure", + "variables_hint": "Available variables are auto-substituted on send.", + "saved": "Alimtalk binding saved.", + "save_error": "Failed to save alimtalk binding." + }, + "banner": { + "not_ready": "Setup required for delivery is incomplete.", + "setup_action": "Set up", + "test_mode": "Test mode — messages are not actually sent." + }, "templates": { "title": "Alimtalk Templates", "description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 8dca6795..024fd0ed 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -62,6 +62,22 @@ "templates": "알림톡 템플릿" } }, + "binding": { + "section_title": "카카오 알림톡 연동", + "section_hint": "선택하면 즉시 저장됩니다. 본문 저장과 별개로 동작합니다.", + "connected_template": "연결 템플릿", + "none": "연결 안 함", + "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", + "fallback_sms": "실패 시 SMS로 대체발송", + "variables_hint": "제공 변수는 발송 시 자동 치환됩니다.", + "saved": "알림톡 연동을 저장했습니다.", + "save_error": "알림톡 연동 저장에 실패했습니다." + }, + "banner": { + "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", + "setup_action": "설정하기", + "test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다." + }, "templates": { "title": "알림톡 템플릿", "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php new file mode 100644 index 00000000..0694e26f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php @@ -0,0 +1,42 @@ + $e->getMessage(), + 'result_code' => $e->getResultCode(), + ], + ); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php index c2353e4b..47a981f4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -8,7 +8,7 @@ use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; +use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreAlimtalkTemplateRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateAlimtalkTemplateRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UploadTemplateImageRequest; @@ -29,6 +29,8 @@ use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; */ class AlimtalkTemplateController extends AdminBaseController { + use GuardsKakaoRequests; + /** * @param AlimtalkTemplateService $service 알림톡 템플릿 서비스 */ @@ -44,6 +46,7 @@ class AlimtalkTemplateController extends AdminBaseController * 쿼리: status(templateStatus)·keyword(2~50자)·page·count * * @param Request $request HTTP 요청 + * @return JsonResponse data 에 templates·pagination */ public function index(Request $request): JsonResponse { @@ -63,6 +66,7 @@ class AlimtalkTemplateController extends AdminBaseController * 알림톡 템플릿 상세를 실시간 조회합니다. * * @param string $templateCode 템플릿 코드 + * @return JsonResponse data.template 에 배지·가능 액션이 부가된 상세 */ public function show(string $templateCode): JsonResponse { @@ -73,6 +77,8 @@ class AlimtalkTemplateController extends AdminBaseController /** * 템플릿 등록에 사용할 카테고리 전체를 조회합니다. + * + * @return JsonResponse data.categories 에 대분류·소분류 목록 */ public function categories(): JsonResponse { @@ -83,6 +89,8 @@ class AlimtalkTemplateController extends AdminBaseController /** * 발신프로필(사용중) 정보를 조회합니다. + * + * @return JsonResponse data.profiles 에 발신프로필 상태 정보 */ public function profiles(): JsonResponse { @@ -98,6 +106,7 @@ class AlimtalkTemplateController extends AdminBaseController * templateImageUrl 로 사용한다. * * @param UploadTemplateImageRequest $request 이미지 파일 검증 Request + * @return JsonResponse data 에 image_url·image_name */ public function uploadImage(UploadTemplateImageRequest $request): JsonResponse { @@ -120,6 +129,7 @@ class AlimtalkTemplateController extends AdminBaseController * 알림톡 템플릿을 신규 등록합니다. * * @param StoreAlimtalkTemplateRequest $request 유형별 검증 Request + * @return JsonResponse data.template 에 등록된 템플릿(검수요청 시 REQ 상태) */ public function store(StoreAlimtalkTemplateRequest $request): JsonResponse { @@ -154,6 +164,7 @@ class AlimtalkTemplateController extends AdminBaseController * * @param UpdateAlimtalkTemplateRequest $request 유형별 검증 Request * @param string $templateCode 기존 템플릿 코드 + * @return JsonResponse data.template 에 수정된 템플릿 */ public function update(UpdateAlimtalkTemplateRequest $request, string $templateCode): JsonResponse { @@ -167,6 +178,7 @@ class AlimtalkTemplateController extends AdminBaseController * 알림톡 템플릿을 삭제합니다. (대기 + 검수 REG/REJ 상태만) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 삭제 완료 응답 */ public function destroy(string $templateCode): JsonResponse { @@ -182,6 +194,7 @@ class AlimtalkTemplateController extends AdminBaseController * * @param Request $request HTTP 요청(comment 선택) * @param string $templateCode 템플릿 코드 + * @return JsonResponse 검수 요청 완료 응답 */ public function requestInspection(Request $request, string $templateCode): JsonResponse { @@ -196,6 +209,7 @@ class AlimtalkTemplateController extends AdminBaseController * 검수 요청을 취소합니다. (검수 REQ 상태만) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 검수 취소 완료 응답 */ public function cancelRequest(string $templateCode): JsonResponse { @@ -206,6 +220,7 @@ class AlimtalkTemplateController extends AdminBaseController * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 중지 완료 응답 */ public function stop(string $templateCode): JsonResponse { @@ -216,6 +231,7 @@ class AlimtalkTemplateController extends AdminBaseController * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 중지 해제 완료 응답 */ public function reuse(string $templateCode): JsonResponse { @@ -226,6 +242,7 @@ class AlimtalkTemplateController extends AdminBaseController * 승인을 취소합니다(재검수 가능 상태로 복귀). (RDY/ACT 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 승인 취소 완료 응답 */ public function cancelApproval(string $templateCode): JsonResponse { @@ -236,6 +253,7 @@ class AlimtalkTemplateController extends AdminBaseController * 휴면 템플릿을 해제합니다. (DMT 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 휴면 해제 완료 응답 */ public function release(string $templateCode): JsonResponse { @@ -266,19 +284,5 @@ class AlimtalkTemplateController extends AdminBaseController * * @param callable(): JsonResponse $callback 실제 처리 */ - private function guard(callable $callback): JsonResponse - { - try { - return $callback(); - } catch (BizppurioApiException $e) { - return ResponseHelper::error( - 'sirsoft-message_bizppurio::messages.error.kakao_request_failed', - 422, - [ - 'kakao_message' => $e->getMessage(), - 'result_code' => $e->getResultCode(), - ], - ); - } - } + // guard() 는 GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유). } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php new file mode 100644 index 00000000..c7778ce6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php @@ -0,0 +1,95 @@ + $this->service->all(), + ]); + } + + /** + * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 드롭다운). + * + * kapi 실패는 카카오가 준 사유를 그대로 422 로 반환한다. + * + * @return JsonResponse data.templates 에 승인 템플릿(code/name) 배열, 실패 시 422 + */ + public function approvedTemplates(): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'templates' => $this->service->approvedTemplates(), + ])); + } + + /** + * 연결 템플릿·SMS 대체를 즉시 저장합니다 (전용 칸 변경 시 자동 호출). + * + * 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다("빈 코드=해제" 규칙 — + * 드롭다운에서 "연결 안 함"을 고르면 해제까지 한 번에 처리). SMS 대체는 연결이 있을 때만 + * 의미가 있다. + * + * @param StoreNotificationBindingRequest $request 검증된 연동 입력 + * @return JsonResponse 저장 결과 (해제 시에도 200) + */ + public function store(StoreNotificationBindingRequest $request): JsonResponse + { + $validated = $request->validated(); + + $this->service->applyFromTemplateSave( + $validated['notification_type'], + $validated['template_code'] ?? null, + $validated['template_name'] ?? null, + (bool) ($validated['fallback_sms_enabled'] ?? false), + ); + + return ResponseHelper::success('messages.binding.saved', [ + 'bindings' => $this->service->all(), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php new file mode 100644 index 00000000..4a7f9e9d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'notification_type' => ['required', 'string', 'max:100'], + 'template_code' => ['nullable', 'string', 'max:30'], + 'template_name' => ['nullable', 'string', 'max:255'], + 'fallback_sms_enabled' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 3612d29f..239e94c8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -23,8 +23,10 @@ use App\Services\PluginSettingsService; * ChannelManager 에 등록되어야 발화하므로, 드라이버 등록은 ServiceProvider::boot() 가 * 담당한다(이 리스너는 채널 "노출·판정"만 책임진다). * - * alimtalk 의 uses_custom_list 플래그(알림톡 탭 배타 전환)는 탭 UI 주입(§6-2)과 강결합이라 - * Phase 6 에서 이 리스너에 추가한다. Phase 3 에서는 채널 메타·readiness·발송 위임 골격만 둔다. + * 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 사용한다(Phase 6 재설계, 계획서 ⚑⚑ 블록 A). + * 연결 템플릿·SMS 대체 등 알림톡 전용 설정은 코어 편집 모달에 전용 칸을 overlay 로 얹고 + * 코어 저장 훅(core.notification_template.filter_update_data/after_update)에 편승해 저장하므로, + * 이 리스너는 코어 목록을 숨기는 별도 플래그를 두지 않는다. */ class RegisterNotificationChannelsListener implements HookListenerInterface { @@ -105,8 +107,14 @@ class RegisterNotificationChannelsListener implements HookListenerInterface { $existingIds = array_column($channels, 'id'); + // 검수(테스트) 모드 여부를 채널 메타에 실어 프론트(availableChannels)로 전달한다. + // 알림톡·SMS 탭 상단 상태 배너가 이 값으로 "테스트 모드 — 실제 발송 안 됨"을 노출한다. + // 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다. + $isTestMode = $this->isTestMode(); + foreach ($this->channelMetas() as $meta) { if (! in_array($meta['id'], $existingIds, true)) { + $meta['is_test_mode'] = $isTestMode; $channels[] = $meta; } } @@ -245,6 +253,18 @@ class RegisterNotificationChannelsListener implements HookListenerInterface return trim((string) $value) === ''; } + /** + * 검수(테스트) 모드 여부를 반환합니다. + * + * is_test_mode=true 이면 실제 발송이 이뤄지지 않는다(검수 환경). 상태 배너 노출 기준. + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } + /** * ready=false 판정을 lang reason key 와 함께 반환합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php new file mode 100644 index 00000000..f0455c70 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php @@ -0,0 +1,260 @@ + [...], ...]` (키가 type) + * - 모듈: 순차 배열 `[['type' => 'order_confirmed', ...], ...]` + * 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다. + * + * 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은 + * 문자/알림톡 대상이 아니므로 건너뛴다. 기본 body 는 그 알림의 database 채널 body(짧은 평문)를 + * 재활용하고, 없으면 mail body 의 HTML 을 제거해 만든다. + */ +class SeedChannelTemplatesListener implements HookListenerInterface +{ + /** 증강할 채널 id 목록 */ + private const CHANNELS = ['sms', 'alimtalk']; + + /** + * 구독할 훅 목록 반환. + * + * @return array> + */ + public static function getSubscribedHooks(): array + { + $hooks = []; + foreach ([ + 'seed.notifications.translations', + 'seed.sirsoft-board.notifications.translations', + 'seed.sirsoft-ecommerce.notifications.translations', + ] as $hook) { + $hooks[$hook] = [ + 'method' => 'augment', + 'priority' => 50, + 'type' => 'filter', + ]; + } + + return $hooks; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 정의 배열의 각 회원 대상 알림에 sms·alimtalk 채널 template 을 증강합니다. + * + * 키(코어=type, 모듈=정수)와 순서를 보존한 채 각 정의 값만 변형한다. 정의 형태가 배열이 + * 아니거나 회원 대상이 아니면 원본을 그대로 둔다. + * + * @param array $definitions 시딩 직전 알림 정의 배열 + * @return array 채널이 증강된 정의 배열 + */ + public function augment(array $definitions): array + { + foreach ($definitions as $key => $definition) { + if (! is_array($definition) || ! $this->isMemberFacing($definition)) { + continue; + } + + $definitions[$key] = $this->augmentDefinition($definition); + } + + return $definitions; + } + + /** + * 단일 알림 정의에 sms·alimtalk 채널을 channels·templates 에 추가합니다. + * + * 이미 해당 채널이 선언돼 있으면(다른 경로로 이미 존재) 중복 추가하지 않는다. + * + * @param array $definition 단일 알림 정의 + * @return array 증강된 정의 + */ + private function augmentDefinition(array $definition): array + { + $channels = $definition['channels'] ?? ['mail']; + $templates = $definition['templates'] ?? []; + + $existingChannels = array_column($templates, 'channel'); + $baseBody = $this->resolveBaseBody($templates); + $baseSubject = $this->resolveBaseSubject($templates); + $recipients = $this->resolveRecipients($templates); + + foreach (self::CHANNELS as $channel) { + if (in_array($channel, $existingChannels, true)) { + continue; + } + + if (! in_array($channel, $channels, true)) { + $channels[] = $channel; + } + + $templates[] = [ + 'channel' => $channel, + 'recipients' => $recipients, + 'subject' => $baseSubject, + 'body' => $baseBody, + ]; + } + + $definition['channels'] = $channels; + $definition['templates'] = $templates; + + return $definition; + } + + /** + * 알림이 회원(사용자) 대상인지 판정합니다 (결정 E). + * + * 정의의 어떤 template recipients 든 관리자 전용(type=role, value=admin)이 아닌 수신자가 + * 하나라도 있으면 회원 대상으로 본다. recipients 가 전혀 없으면(수신자 미지정) 회원 발송 + * 기본값으로 포함한다. channels 컬럼이 아니라 recipients 로 판정한다(결정 E). + * + * @param array $definition 단일 알림 정의 + * @return bool 회원 대상이면 true + */ + private function isMemberFacing(array $definition): bool + { + $templates = $definition['templates'] ?? []; + $hasAnyRecipient = false; + + foreach ($templates as $template) { + foreach ($template['recipients'] ?? [] as $recipient) { + $hasAnyRecipient = true; + + $type = $recipient['type'] ?? null; + $value = $recipient['value'] ?? null; + + if (! ($type === 'role' && $value === 'admin')) { + return true; + } + } + } + + return ! $hasAnyRecipient; + } + + /** + * 증강할 채널의 기본 body(다국어 배열)를 결정합니다. + * + * 우선순위: database 채널 body(짧은 평문) → mail body 의 HTML 제거본 → 빈 배열. + * 문자/알림톡은 평문이므로 HTML 을 담지 않는다. + * + * @param array> $templates 기존 template 목록 + * @return array locale → 평문 body + */ + private function resolveBaseBody(array $templates): array + { + $database = $this->findTemplate($templates, 'database'); + if ($database !== null && is_array($database['body'] ?? null)) { + return $database['body']; + } + + $mail = $this->findTemplate($templates, 'mail'); + if ($mail !== null && is_array($mail['body'] ?? null)) { + return array_map(fn ($body) => $this->stripHtml((string) $body), $mail['body']); + } + + return []; + } + + /** + * 증강할 채널의 기본 subject(다국어 배열)를 결정합니다. + * + * database → mail subject 순으로 재활용한다. LMS 전환 시 제목으로 쓰인다. + * + * @param array> $templates 기존 template 목록 + * @return array locale → subject + */ + private function resolveBaseSubject(array $templates): array + { + foreach (['database', 'mail'] as $channel) { + $template = $this->findTemplate($templates, $channel); + if ($template !== null && is_array($template['subject'] ?? null)) { + return $template['subject']; + } + } + + return []; + } + + /** + * 증강할 채널의 recipients 를 결정합니다. + * + * 기존 template(database → mail)의 recipients 를 그대로 재활용해 수신자 규칙을 일치시킨다. + * 없으면 발생 회원 본인(trigger_user) 기본값을 쓴다. + * + * @param array> $templates 기존 template 목록 + * @return array> recipients 규칙 + */ + private function resolveRecipients(array $templates): array + { + foreach (['database', 'mail'] as $channel) { + $template = $this->findTemplate($templates, $channel); + if ($template !== null && ! empty($template['recipients']) && is_array($template['recipients'])) { + return $template['recipients']; + } + } + + return [['type' => 'trigger_user']]; + } + + /** + * template 목록에서 특정 채널의 template 을 찾습니다. + * + * @param array> $templates template 목록 + * @param string $channel 찾을 채널 + * @return array|null 매칭 template 또는 null + */ + private function findTemplate(array $templates, string $channel): ?array + { + foreach ($templates as $template) { + if (($template['channel'] ?? null) === $channel) { + return $template; + } + } + + return null; + } + + /** + * HTML 문자열을 문자/알림톡용 평문으로 변환합니다. + * + * 태그 제거 + 엔티티 디코드 + 연속 공백 정리. 변수 표기({name} 등)는 보존된다. + * + * @param string $html 원본 HTML + * @return string 평문 + */ + private function stripHtml(string $html): string + { + $text = preg_replace('/<[^>]+>/', ' ', $html) ?? ''; + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim((string) preg_replace('/\s+/', ' ', $text)); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 86e75ffd..774cedf1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -6,11 +6,11 @@ namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; use Illuminate\Notifications\ChannelManager; -use Illuminate\Notifications\Notification; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService; @@ -75,9 +75,9 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * SmsChannelDriver::send() 로 위임되게 한다. 플러그인 비활성/삭제 시 이 등록도 * 사라져 코어가 안전하게 원복된다. * - * 알림톡('alimtalk')은 채널 등록(RegisterNotificationChannelsListener)만 이번 Phase 에 - * 하고 실제 발송 드라이버는 Phase 6 이다. 그 사이 alimtalk 채널로 발송이 트리거될 경우의 - * 크래시를 막기 위해, 아직은 no-op 스텁 드라이버로 등록해 둔다(Phase 6 에서 실드라이버로 교체). + * 알림톡('alimtalk')도 동일하게 AlimtalkChannelDriver 로 위임한다(Phase 6). 관리자가 + * 알림톡 탭에서 연결한 승인 템플릿(binding)이 있을 때만 실제 발송되고, 미연결 알림은 + * 드라이버가 조용히 skip 한다. */ public function boot(): void { @@ -100,22 +100,14 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * 코어 알림 ChannelManager 에 이 플러그인의 채널 드라이버를 등록합니다. * * - sms: SmsChannelDriver 로 실제 발송 위임(Phase 3). - * - alimtalk: 채널 등록만 이번 Phase(§6-2·Phase 6). 실발송 전까지 no-op 스텁으로 - * 등록해 alimtalk 발송 트리거 시 "Driver not supported" 크래시를 막는다. + * - alimtalk: AlimtalkChannelDriver 로 실제 발송 위임(Phase 6). 연결된 승인 템플릿 + * (binding)이 있을 때만 발송하고 미연결 알림은 드라이버가 skip 한다. * * @param ChannelManager $manager 코어 알림 채널 매니저 */ private function registerChannelDrivers(ChannelManager $manager): void { $manager->extend('sms', fn ($app) => $app->make(SmsChannelDriver::class)); - - $manager->extend('alimtalk', fn () => new class - { - /** - * @param object $notifiable - * @param Notification $notification - */ - public function send($notifiable, $notification): void {} - }); + $manager->extend('alimtalk', fn ($app) => $app->make(AlimtalkChannelDriver::class)); } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php new file mode 100644 index 00000000..4b8f5bf9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -0,0 +1,217 @@ +getType(); + + // 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송) + $binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value); + if ($binding === null) { + Log::info('비즈뿌리오 알림톡 발송 skip — 연결된 템플릿 없음', ['type' => $type]); + + return; + } + + // 2. 코어 alimtalk 템플릿 본문 resolve (없으면 발송 안 함 — 기본 body 시드 전제) + $template = $this->templateService->resolve($type, DispatchChannel::Alimtalk->value); + if ($template === null || ! $template->is_active) { + Log::info('비즈뿌리오 알림톡 발송 skip — alimtalk 템플릿 없음', ['type' => $type]); + + return; + } + + // 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) + $to = $this->resolvePhone($notifiable, $notification->getData()); + if ($to === null) { + Log::info('비즈뿌리오 알림톡 발송 skip — 전화번호 없음', ['type' => $type]); + + return; + } + + // 4. 본문 렌더 (변수 치환) → 알림톡 변수 형식으로 변환 ({x} → #{x}) + $locale = BaseNotification::resolveNotifiableLocale($notifiable); + $rendered = $template->replaceVariables($notification->getData(), $locale); + $message = $this->toAlimtalkVariables((string) ($rendered['body'] ?? '')); + if (trim($message) === '') { + Log::info('비즈뿌리오 알림톡 발송 skip — 본문 비어 있음', ['type' => $type]); + + return; + } + + // 5. refkey 생성 → payload 조립 (대체발송 ON 시 SMS resend 병합) + $refkey = $this->generateRefkey(); + $payload = $this->payloadBuilder->buildAlimtalk($to, $binding->template_code, $message, $refkey); + + if ($binding->fallback_sms_enabled) { + $payload = $this->withSmsFallback($payload, (string) ($rendered['body'] ?? '')); + } + + // 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신. + $this->dispatches->create([ + 'refkey' => $refkey, + 'channel' => DispatchChannel::Alimtalk->value, + 'to_number' => $to, + 'to_name' => $notifiable->name ?? null, + 'to_user_id' => $this->resolveUserId($notifiable), + 'content' => $message, + 'notification_type' => $type, + 'status' => DispatchStatus::Pending->value, + 'source' => DispatchSource::Auto->value, + 'sent_at' => now(), + ]); + + SendMessageJob::dispatch($payload, $refkey); + } + + /** + * 코어 알림 본문의 변수 형식({name})을 알림톡 변수 형식(#{name})으로 변환합니다 (D8). + * + * 코어 알림 템플릿과 카카오 알림톡은 변수명 규칙이 동일하되 표기만 다르다. 변수명을 그대로 + * 유지한 채 접두 `#` 만 붙여, 매핑 UI·컬럼 없이 자동 치환한다. 이미 `#{...}` 인 경우는 + * 중복 변환하지 않는다. + * + * @param string $body 코어 본문 (예: "{name} 님 주문 {order_number}") + * @return string 알림톡 본문 (예: "#{name} 님 주문 #{order_number}") + */ + private function toAlimtalkVariables(string $body): string + { + // 앞에 # 가 없는 {var} 만 #{var} 로 변환 (이미 #{var} 인 경우 보존). + return (string) preg_replace('/(? $payload 알림톡 발송 payload + * @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용) + * @return array resend/recontent 가 병합된 payload + */ + private function withSmsFallback(array $payload, string $renderedBody): array + { + $payload['resend'] = ['first' => 'sms']; + $payload['recontent'] = ['sms' => ['message' => $renderedBody]]; + + return $payload; + } + + /** + * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다. + * + * @param object $notifiable 수신자 + * @return int|null 회원 ID 또는 null + */ + private function resolveUserId(object $notifiable): ?int + { + if ($notifiable instanceof User) { + return (int) $notifiable->getKey(); + } + + return null; + } + + /** + * 수신자의 전화번호를 해석합니다 (SmsChannelDriver 와 동일 규칙). + * + * 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다. + * 숫자 외 문자는 제거하고, 값이 없으면 null 을 반환한다. + * + * @param object $notifiable 수신자 + * @param array $data 알림 data + * @return string|null 정규화된 전화번호 또는 null + */ + private function resolvePhone(object $notifiable, array $data): ?string + { + $raw = $notifiable->mobile + ?? ($data[self::RECIPIENT_PHONE_KEY] ?? null); + + $normalized = preg_replace('/[^0-9]/', '', (string) $raw); + + return ($normalized === null || $normalized === '') ? null : $normalized; + } + + /** + * webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다. + * + * @return string 32자 refkey + */ + private function generateRefkey(): string + { + return Str::random(32); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php new file mode 100644 index 00000000..eba3bd60 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php @@ -0,0 +1,151 @@ +> notification_type 키의 연동 맵 + */ + public function all(): array + { + return $this->bindings->allByChannel(self::CHANNEL) + ->keyBy('notification_type') + ->map(fn (BizppurioNotificationBinding $binding) => [ + 'notification_type' => $binding->notification_type, + 'template_code' => $binding->template_code, + 'template_name' => $binding->template_name, + 'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled, + ]) + ->all(); + } + + /** + * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운). + * + * 카카오 템플릿 목록 중 serviceStatus 가 RDY/ACT 인 항목만 노출한다. 미승인 템플릿에 + * 연동해도 발송이 거부되므로 애초에 선택지에서 제외한다. + * + * @return array + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function approvedTemplates(): array + { + // 승인 상태 필터는 kapi 에 status 파라미터로 위임하지 않고(상태 2종 조회 불가), 전체를 + // 받아 serviceStatus 로 거른다. 페이지네이션 대신 넉넉한 count 로 1회 조회한다. + $result = $this->templates->list(['count' => 100]); + + return collect($result['templates'] ?? []) + ->filter(fn (array $row) => in_array((string) ($row['serviceStatus'] ?? $row['status'] ?? ''), self::SENDABLE_STATUSES, true)) + ->map(fn (array $row) => [ + 'template_code' => (string) ($row['templateCode'] ?? $row['code'] ?? ''), + 'template_name' => (string) ($row['templateName'] ?? $row['name'] ?? ''), + ]) + ->filter(fn (array $row) => $row['template_code'] !== '') + ->values() + ->all(); + } + + /** + * 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장). + * + * @param string $notificationType 코어 notification_definitions.type + * @param array $data template_code / template_name / fallback_sms_enabled + * @return BizppurioNotificationBinding 저장된 연동 + */ + public function bind(string $notificationType, array $data): BizppurioNotificationBinding + { + return $this->bindings->upsert($notificationType, self::CHANNEL, [ + 'template_code' => (string) $data['template_code'], + 'template_name' => (string) $data['template_name'], + 'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false), + 'is_active' => true, + ]); + } + + /** + * 알림의 알림톡 연동을 해제(삭제)합니다. + * + * @param string $notificationType 코어 notification_definitions.type + */ + public function unbind(string $notificationType): void + { + $this->bindings->delete($notificationType, self::CHANNEL); + } + + /** + * 코어 편집 모달 전용 칸이 넘긴 값으로 연동을 반영합니다 (저장 훅 편승). + * + * 코어 알림 템플릿 [저장] 훅(after_update)에서 호출된다. 연결 템플릿 코드가 비어 있으면 + * 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제" 규칙 덕분에 편집 모달에서 드롭다운을 + * "연결 안 함"으로 바꾸고 저장하면 코어 저장 한 번으로 해제까지 처리된다. + * + * @param string $notificationType 코어 notification_definitions.type + * @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제) + * @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용) + * @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부 + */ + public function applyFromTemplateSave( + string $notificationType, + ?string $templateCode, + ?string $templateName, + bool $fallbackSmsEnabled, + ): void { + $code = trim((string) $templateCode); + + if ($code === '') { + $this->unbind($notificationType); + + return; + } + + $this->bind($notificationType, [ + 'template_code' => $code, + 'template_name' => trim((string) $templateName), + 'fallback_sms_enabled' => $fallbackSmsEnabled, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index e5897118..30d29971 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Middleware\EnforceIdentityPolicy; use App\Http\Middleware\RefreshTokenExpiration; use Illuminate\Support\Facades\Route; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController; +use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController; use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController; use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; @@ -82,4 +83,27 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g Route::post('/{templateCode}/release', [AlimtalkTemplateController::class, 'release'])->name('release'); }); }); + + /* + |---------------------------------------------------------------------- + | 알림↔알림톡 템플릿 연동 (Phase 6 재설계) — 코어 편집 모달 전용 칸이 소비 + |---------------------------------------------------------------------- + | + | 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체 + | 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이 API + | 로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼 무관 → + | 코어 템플릿 무오염). + | + | 조회(index/approved-templates) = messaging.view / 저장(store) = messaging.manage. + */ + Route::prefix('notification-bindings')->name('notification-bindings.')->group(function () { + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + Route::get('/', [NotificationBindingController::class, 'index'])->name('index'); + Route::get('/approved-templates', [NotificationBindingController::class, 'approvedTemplates'])->name('approved-templates'); + }); + + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { + Route::post('/', [NotificationBindingController::class, 'store'])->name('store'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php new file mode 100644 index 00000000..2b58b5d4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php @@ -0,0 +1,191 @@ + $permissionIds 부여할 권한 식별자 + */ + private function adminWith(array $permissionIds): User + { + $user = User::factory()->create(); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $permIds = []; + foreach ($permissionIds as $identifier) { + $permission = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin'] + ); + $permIds[] = $permission->id; + } + + $testRole = Role::create([ + 'identifier' => 'bizppurio_binding_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync($permIds); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * 사용자 토큰으로 요청 헤더를 만듭니다. + */ + private function authHeaders(User $user): array + { + return [ + 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken, + 'Accept' => 'application/json', + ]; + } + + public function test_인증_없이_목록_조회는_401이다(): void + { + $this->getJson(self::BASE)->assertStatus(401); + } + + public function test_view_권한으로_연동_맵을_조회한다(): void + { + BizppurioNotificationBinding::create([ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => true, + 'is_active' => true, + ]); + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE); + + $response->assertStatus(200); + $response->assertJsonPath('data.bindings.welcome.template_code', 'TW_1236'); + $response->assertJsonPath('data.bindings.welcome.fallback_sms_enabled', true); + } + + public function test_연동이_없으면_빈_맵을_반환한다(): void + { + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE); + + $response->assertStatus(200); + $response->assertJsonPath('data.bindings', []); + } + + public function test_권한_없는_사용자는_승인템플릿_조회_403이다(): void + { + $admin = $this->adminWith([]); // messaging.view 없음 + + $response = $this->withHeaders($this->authHeaders($admin)) + ->getJson(self::BASE.'/approved-templates'); + + $response->assertStatus(403); + } + + public function test_view_권한만으로는_저장할_수_없다(): void + { + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + ]); + + $response->assertStatus(403); + } + + public function test_manage_권한으로_연동을_즉시_저장한다(): void + { + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => true, + ]); + + $response->assertStatus(200); + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'fallback_sms_enabled' => true, + ]); + } + + public function test_빈_코드로_저장하면_연동을_해제한다(): void + { + BizppurioNotificationBinding::create([ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => false, + 'is_active' => true, + ]); + + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => '', + ]); + + $response->assertStatus(200); + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + ]); + } + + public function test_notification_type_누락_시_422다(): void + { + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'template_code' => 'TW_1236', + ]); + + $response->assertStatus(422); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php index ca89a6be..4e31bd6f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php @@ -72,13 +72,39 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase $this->assertTrue($alimtalk['allow_guest']); } - public function test_알림톡_채널에는_uses_custom_list_플래그가_아직_없다(): void + public function test_채널메타에_is_test_mode가_실린다(): void { - // 탭 배타 전환 플래그는 Phase 6 — 이번 Phase 채널 메타에는 없어야 한다. - $channels = $this->makeListener()->addChannels([]); - $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + // is_test_mode=true(검수) → 상태 배너 노출 기준. 채널 메타에 실려 프론트로 전달된다. + $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => true])); + $channels = $listener->addChannels([]); - $this->assertArrayNotHasKey('uses_custom_list', $alimtalk); + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertTrue($alimtalk['is_test_mode'], '검수 모드면 채널 메타 is_test_mode=true.'); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertTrue($sms['is_test_mode']); + } + + public function test_검수모드_해제시_is_test_mode가_false다(): void + { + $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => false])); + $channels = $listener->addChannels([]); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertFalse($alimtalk['is_test_mode'], '운영 모드면 is_test_mode=false.'); + } + + public function test_어떤_채널에도_uses_custom_list_플래그가_없다(): void + { + // Phase 6 재설계 A(⚑⚑ 결정 A): 알림톡 탭도 코어 기본 목록을 그대로 쓴다. 코어 목록을 + // 숨기던 uses_custom_list 게이트·플래그는 폐기되었으므로 어느 채널에도 있으면 안 된다. + $channels = $this->makeListener()->addChannels([]); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertArrayNotHasKey('uses_custom_list', $alimtalk, '알림톡은 코어 기본 목록을 쓰므로 플래그가 없어야 한다(재설계 A).'); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertArrayNotHasKey('uses_custom_list', $sms, 'sms 는 코어 기본 목록을 쓰므로 플래그가 없어야 한다.'); } public function test_이미_존재하는_채널은_중복_추가되지_않는다(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php new file mode 100644 index 00000000..d4d7a57a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php @@ -0,0 +1,149 @@ + 'trigger_user']]): array + { + return [ + 'welcome' => [ + 'hook_prefix' => 'core.auth', + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => $recipients, 'subject' => ['ko' => '제목'], 'body' => ['ko' => '

{name}

']], + ['channel' => 'database', 'recipients' => $recipients, 'subject' => ['ko' => '알림'], 'body' => ['ko' => '{name}님 환영합니다']], + ], + ], + ]; + } + + public function test_회원_알림에_sms와_alimtalk_채널이_추가된다(): void + { + $result = $this->listener()->augment($this->coreDefinition()); + + $channels = $result['welcome']['channels']; + $this->assertContains('sms', $channels); + $this->assertContains('alimtalk', $channels); + $this->assertContains('mail', $channels, '기존 채널은 보존되어야 한다.'); + + $addedChannels = array_column($result['welcome']['templates'], 'channel'); + $this->assertContains('sms', $addedChannels); + $this->assertContains('alimtalk', $addedChannels); + } + + public function test_추가된_채널_body는_database_평문을_재활용한다(): void + { + $result = $this->listener()->augment($this->coreDefinition()); + + $alimtalk = collect($result['welcome']['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertSame('{name}님 환영합니다', $alimtalk['body']['ko'], 'database 평문 body 를 재활용해야 한다(HTML 아님).'); + } + + public function test_database가_없으면_mail_body의_htm_l을_제거해_쓴다(): void + { + $def = [ + 'welcome' => [ + 'channels' => ['mail'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '

{name}님

환영합니다

']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $sms = collect($result['welcome']['templates'])->firstWhere('channel', 'sms'); + $this->assertSame('{name}님 환영합니다', $sms['body']['ko'], 'HTML 태그는 제거되고 변수는 보존되어야 한다.'); + } + + public function test_관리자_전용_알림은_증강되지_않는다(): void + { + $def = [ + 'balance_low' => [ + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'role', 'value' => 'admin']], 'body' => ['ko' => 'x']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $channels = $result['balance_low']['channels']; + $this->assertNotContains('sms', $channels, '관리자 전용 알림에는 문자/알림톡을 추가하지 않는다.'); + $this->assertNotContains('alimtalk', $channels); + } + + public function test_모듈형_순차배열도_증강한다(): void + { + // 모듈: getNotificationDefinitions() 는 순차 배열 [['type'=>..., ...], ...] + $def = [ + [ + 'type' => 'order_confirmed', + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'database', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '주문 완료']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $this->assertContains('alimtalk', $result[0]['channels']); + $this->assertSame('order_confirmed', $result[0]['type'], 'type 등 기존 필드는 보존되어야 한다.'); + } + + public function test_이미_alimtalk이_있으면_중복_추가하지_않는다(): void + { + $def = [ + 'welcome' => [ + 'channels' => ['mail', 'alimtalk'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => 'x']], + ['channel' => 'alimtalk', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '기존']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $alimtalkCount = count(array_filter($result['welcome']['templates'], fn ($t) => $t['channel'] === 'alimtalk')); + $this->assertSame(1, $alimtalkCount, 'alimtalk template 이 중복 추가되면 안 된다.'); + } + + public function test_recipients가_없는_알림은_회원_대상으로_보고_증강한다(): void + { + $def = [ + 'generic' => [ + 'channels' => ['mail'], + 'templates' => [ + ['channel' => 'mail', 'body' => ['ko' => '알림']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $this->assertContains('alimtalk', $result['generic']['channels']); + // recipients 미지정 → 기본 trigger_user + $alimtalk = collect($result['generic']['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertSame('trigger_user', $alimtalk['recipients'][0]['type']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php new file mode 100644 index 00000000..e45adb08 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -0,0 +1,306 @@ + $rendered replaceVariables 반환값 + */ + private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '{name} 님 주문이 완료되었습니다.']): NotificationTemplate + { + $template = Mockery::mock(NotificationTemplate::class)->makePartial(); + $template->is_active = true; + $template->shouldReceive('replaceVariables')->andReturn($rendered); + + return $template; + } + + /** + * findActive 반환값을 지정한 binding 리포지토리 mock 을 만듭니다. + * + * @param BizppurioNotificationBinding|null $binding findActive 반환값 + */ + private function fakeBindings(?BizppurioNotificationBinding $binding): BizppurioNotificationBindingRepositoryInterface + { + $repo = Mockery::mock(BizppurioNotificationBindingRepositoryInterface::class); + $repo->shouldReceive('findActive') + ->with(Mockery::any(), 'alimtalk') + ->andReturn($binding); + + return $repo; + } + + /** + * 연결(binding) 모델을 만듭니다. + */ + private function binding(bool $fallback = false): BizppurioNotificationBinding + { + $binding = new BizppurioNotificationBinding; + $binding->notification_type = 'order_confirmed'; + $binding->channel = 'alimtalk'; + $binding->template_code = 'TW_1234'; + $binding->template_name = '주문완료'; + $binding->fallback_sms_enabled = $fallback; + $binding->is_active = true; + + return $binding; + } + + /** + * 템플릿·binding·빌더를 조합한 driver 를 만듭니다. + */ + private function makeDriver( + ?NotificationTemplate $template, + ?BizppurioNotificationBinding $binding, + ?MessagePayloadBuilder $builder = null, + ): AlimtalkChannelDriver { + $templateService = Mockery::mock(NotificationTemplateService::class); + $templateService->shouldReceive('resolve') + ->with(Mockery::any(), 'alimtalk') + ->andReturn($template); + + return new AlimtalkChannelDriver( + $templateService, + $this->fakeBindings($binding), + $builder ?? $this->spyBuilder(), + new BizppurioDispatchRepository, + ); + } + + /** + * buildAlimtalk 호출 인자를 기록하는 payload 빌더 스파이를 만듭니다. + */ + private function spyBuilder(): MessagePayloadBuilder + { + return new class extends MessagePayloadBuilder + { + /** @var array> */ + public array $calls = []; + + public function __construct() {} + + public function buildAlimtalk( + string $to, + string $templateCode, + string $message, + string $refkey, + array $extra = [], + ): array { + $this->calls[] = [ + 'to' => $to, + 'templateCode' => $templateCode, + 'message' => $message, + 'refkey' => $refkey, + ]; + + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'templatecode' => $templateCode]; + } + }; + } + + private function notification(array $data = ['name' => '김철수']): GenericNotification + { + return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); + } + + public function test_연결된_템플릿이_없으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // binding = null → 미연결 → skip + $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification()); + + Bus::assertNotDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + + public function test_alimtalk_템플릿이_없으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // binding 은 있으나 코어 alimtalk 본문 템플릿이 없음 → skip + $this->makeDriver(null, $this->binding())->send($member, $this->notification()); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_회원은_mobile로_연결된_템플릿코드로_발송한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) + ->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertCount(1, $builder->calls); + $this->assertSame('01012345678', $builder->calls[0]['to']); + $this->assertSame('TW_1234', $builder->calls[0]['templateCode'], '연결된 카카오 템플릿 코드로 발송해야 한다.'); + } + + public function test_비회원은_data의_전화번호로_발송한다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) + ->send($guest, $this->notification($data)); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertSame('01099990000', $builder->calls[0]['to']); + } + + public function test_본문_변수를_알림톡_형식으로_변환한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + $builder = $this->spyBuilder(); + + $this->makeDriver( + $this->fakeTemplate(['subject' => '', 'body' => '{name} 님 {order_number} 주문 완료']), + $this->binding(), + $builder, + )->send($member, $this->notification()); + + $this->assertSame( + '#{name} 님 #{order_number} 주문 완료', + $builder->calls[0]['message'], + '코어 {var} 형식을 알림톡 #{var} 형식으로 치환해야 한다.', + ); + } + + public function test_이미_알림톡_형식인_변수는_중복_변환하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + $builder = $this->spyBuilder(); + + $this->makeDriver( + $this->fakeTemplate(['subject' => '', 'body' => '#{name} 님 {order_number}']), + $this->binding(), + $builder, + )->send($member, $this->notification()); + + $this->assertSame( + '#{name} 님 #{order_number}', + $builder->calls[0]['message'], + '이미 #{var} 인 변수는 ##{var} 로 이중 변환되면 안 된다.', + ); + } + + public function test_대체발송_o_n이면_payload에_sms_resend가_병합된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + // 실제 payload 병합을 관찰하려면 실 빌더가 필요하므로, buildAlimtalk 만 최소 stub 하고 + // withSmsFallback 결과를 dispatch 된 Job payload 로 검증한다. + $builder = new class extends MessagePayloadBuilder + { + public function __construct() {} + + public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array + { + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => ['message' => $message]]]; + } + }; + + // replaceVariables 는 치환 완료본을 반환하므로 mock body 도 치환 완료 텍스트로 준다. + // 드라이버는 이 body 를 알림톡 본문(#{var} 변환)과 대체 SMS 본문(원문 그대로) 두 곳에 쓴다. + $this->makeDriver( + $this->fakeTemplate(['subject' => '', 'body' => '김철수 님 주문 완료']), + $this->binding(fallback: true), + $builder, + )->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { + $this->assertSame(['first' => 'sms'], $job->payload['resend'] ?? null, '대체발송 ON 은 resend:{first:sms} 를 넣어야 한다.'); + $this->assertArrayHasKey('recontent', $job->payload); + $this->assertSame('김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 치환 완료된 코어 본문(#{var} 미변환)이어야 한다.'); + + return true; + }); + } + + public function test_대체발송_of_f이면_resend가_없다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->fakeTemplate(), $this->binding(fallback: false)) + ->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { + $this->assertArrayNotHasKey('resend', $job->payload); + + return true; + }); + } + + public function test_회원_발송_시_pending_이력을_alimtalk_채널로_생성한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']); + + $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 1); + $dispatch = BizppurioDispatch::first(); + $this->assertSame('pending', $dispatch->status->value); + $this->assertSame('alimtalk', $dispatch->channel->value); + $this->assertSame($member->id, $dispatch->to_user_id); + $this->assertSame('order_confirmed', $dispatch->notification_type); + } + + public function test_전화번호가_없으면_발송하지_않는다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + + $this->makeDriver($this->fakeTemplate(), $this->binding()) + ->send($guest, $this->notification(['name' => '홍길동'])); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_generic_notification이_아니면_무시한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, new Notification); + + Bus::assertNotDispatched(SendMessageJob::class); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php new file mode 100644 index 00000000..38d099df --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php @@ -0,0 +1,144 @@ +> $templates list()['templates'] 반환값 + */ + private function fakeTemplateService(array $templates): AlimtalkTemplateService + { + $service = Mockery::mock(AlimtalkTemplateService::class); + $service->shouldReceive('list')->andReturn(['templates' => $templates, 'pagination' => []]); + + return $service; + } + + private function makeService(array $templates = []): NotificationBindingService + { + return new NotificationBindingService( + new BizppurioNotificationBindingRepository, + $this->fakeTemplateService($templates), + ); + } + + public function test_all은_알림톡_연동을_type_키_맵으로_반환한다(): void + { + (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_1234', + 'template_name' => '주문완료', + 'fallback_sms_enabled' => true, + 'is_active' => true, + ]); + + $map = $this->makeService()->all(); + + $this->assertArrayHasKey('order_confirmed', $map); + $this->assertSame('TW_1234', $map['order_confirmed']['template_code']); + $this->assertSame('주문완료', $map['order_confirmed']['template_name']); + $this->assertTrue($map['order_confirmed']['fallback_sms_enabled']); + } + + public function test_all은_연동이_없으면_빈_맵을_반환한다(): void + { + $this->assertSame([], $this->makeService()->all()); + } + + public function test_승인_상태_템플릿만_연결_후보로_노출한다(): void + { + $service = $this->makeService([ + ['templateCode' => 'TW_A', 'templateName' => '승인됨', 'serviceStatus' => 'ACT'], + ['templateCode' => 'TW_B', 'templateName' => '발송전', 'serviceStatus' => 'RDY'], + ['templateCode' => 'TW_C', 'templateName' => '검수중', 'serviceStatus' => 'REQ'], + ['templateCode' => 'TW_D', 'templateName' => '반려', 'serviceStatus' => 'REJ'], + ]); + + $codes = array_column($service->approvedTemplates(), 'template_code'); + + $this->assertContains('TW_A', $codes, 'ACT(정상)는 노출되어야 한다.'); + $this->assertContains('TW_B', $codes, 'RDY(발송전)는 노출되어야 한다.'); + $this->assertNotContains('TW_C', $codes, 'REQ(검수중)는 제외되어야 한다.'); + $this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.'); + } + + public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void + { + $service = $this->makeService(); + + $service->bind('welcome', [ + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => false, + ]); + + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'is_active' => true, + ]); + + $service->unbind('welcome'); + + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + ]); + } + + public function test_bind는_같은_알림에_대해_갱신한다(): void + { + $service = $this->makeService(); + + $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']); + $service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']); + + $this->assertDatabaseCount('bizppurio_notification_bindings', 1); + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_2', + ]); + } + + public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void + { + $this->makeService()->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true); + + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_9', + 'fallback_sms_enabled' => true, + ]); + } + + public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void + { + $service = $this->makeService(); + $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']); + + // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 + $service->applyFromTemplateSave('welcome', '', null, false); + + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml new file mode 100644 index 00000000..ff224438 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml @@ -0,0 +1,101 @@ +# audit:allow test-scenario-coverage reason: | +# Phase 6(재설계)는 알림톡 실발송 위임(백엔드) + 시딩 필터 훅 template 증강 + 코어 편집 모달 +# 전용 칸(즉시 저장) + 상태 배너 overlay 이다. axes cross product 의 런타임 효과는 계층별로 +# 전수 가드된다: +# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/변수치환/대체발송/전화번호/이력. +# - template 증강: SeedChannelTemplatesListenerTest — 회원판정/기본문구/채널추가/멱등. +# - 연동 즉시저장·조회·권한: NotificationBindingServiceTest + NotificationBindingEndpointTest. +# - 채널 메타(is_test_mode): RegisterNotificationChannelsListenerTest. +# - overlay 전용칸·배너·i18n·무오염: notification_tab_binding.test.tsx. +# e2e_* 축(브라우저 왕복: 편집 열기→드롭다운 선택→즉시 저장→toast→배너)은 실제 카카오 발신 +# 프로필·승인 템플릿이 필요해, 계획서 §9 방침대로 PO 직접 수행하는 실연동 검증 단계에서 +# Playwright spec 을 최초 도입하며 그때 @scenario/@effects 를 이 매니페스트에 매핑한다. Vitest +# 렌더/구조 테스트가 프론트 overlay·배너·무오염을 가드하므로 이번 Phase 는 spec 미도입 +# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 커버로 충족). + +feature: 비즈뿌리오 알림톡 채널 실발송 + 알림 설정 편집 모달 연동 (Phase 6 재설계) + +description: | + 회원 대상 알림에 sms·alimtalk 채널 template 을 시딩 필터 훅으로 증강하고(코어/모듈 무수정), + 코어 알림 편집 모달(alimtalk 채널)에 전용 칸을 얹어 승인 알림톡 템플릿을 연결한다. 전용 칸 + 값은 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관해 코어 + 템플릿 무오염). 이벤트 발생 시 연결된 템플릿으로 알림톡을 자동발송한다. + + 핵심 동작: + - SeedChannelTemplatesListener: 코어/모듈 시딩 필터 훅(seed.*.notifications.translations)에 + 편승해 회원 알림에 sms·alimtalk template 증강(cleanupStaleTemplates 에도 유지). + - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. + - 변수 치환: 코어 {var} → 알림톡 #{var}(D8, 매핑 UI 없음). + - 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합. + - 전용 칸(overlay): channel==='alimtalk' 일 때만 안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 + 토글. 변경 즉시 우리 API(store) 저장 + toast. 승인(RDY/ACT) 템플릿만 드롭다운 노출. + - 상태 배너(overlay): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡테스트모드. + +axes: + channel: [alimtalk] + recipient: [member, guest] + binding_state: [bound, unbound] + fallback_sms: [on, off] + member_facing: [member_target, admin_only] + banner_state: [not_ready, test_mode, normal] + e2e_binding_action: [connect, change, unbind] + +exclusions: + - { binding_state: unbound, fallback_sms: on, reason: "미연결 알림은 대체발송 설정 자체가 없음" } + - { member_facing: admin_only, binding_state: bound, reason: "관리자 전용 알림은 증강 대상 아님 — 연동 불가" } + - { recipient: guest, e2e_binding_action: connect, reason: "연동 저장 UI 는 관리자 화면 — 수신자(member/guest)는 발송 축, UI 축과 무관" } + +effects: + # 발송 위임 (AlimtalkChannelDriver) + - alimtalk_dispatched_with_bound_template_code + - dispatch_skipped_when_no_binding + - dispatch_skipped_when_no_alimtalk_template + - dispatch_skipped_when_no_phone + - core_variables_converted_to_alimtalk_hash_form + - already_hash_form_variable_not_double_converted + - member_phone_resolved_from_mobile + - guest_phone_resolved_from_data_recipient_phone + - fallback_on_merges_sms_resend_recontent + - fallback_off_has_no_resend + - pending_dispatch_created_with_alimtalk_channel + # template 증강 (SeedChannelTemplatesListener) + - member_facing_notification_gets_sms_and_alimtalk_channels + - admin_only_notification_not_augmented + - augmented_body_reuses_database_plaintext + - augmented_body_strips_html_when_no_database + - existing_channel_not_duplicated + - module_sequential_array_augmented + # 채널 메타 (RegisterNotificationChannelsListener) + - channel_meta_carries_is_test_mode + - test_mode_off_sets_is_test_mode_false + # 연동 즉시 저장·조회·권한 (NotificationBindingService/Controller) + - store_creates_or_updates_binding_immediately + - store_empty_code_unbinds + - only_approved_templates_offered_as_options + - bindings_map_returned_for_prefill + - view_permission_required_for_read + - manage_permission_required_for_store + # 전용 칸·배너 (프론트 — Vitest 구조/i18n) + - alimtalk_section_shown_only_for_alimtalk_channel + - template_select_saves_immediately_via_our_api + - fallback_toggle_disabled_when_no_template + - save_error_shows_toast + - banner_shown_only_on_sms_alimtalk_tab_when_problem + - not_ready_banner_has_setup_action + - test_mode_banner_shown_when_ready_and_test_mode + - core_edit_modal_save_body_has_no_bizppurio_fields + # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상) + - e2e_open_alimtalk_edit_shows_section + - e2e_select_template_saves_and_toasts + - e2e_unbind_via_none_option_removes_binding + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx + +audit: + status: codified \ No newline at end of file From 17a95bb5566f32f9e8239ab3a06cd120a841e9f8 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 20 Jul 2026 14:42:23 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=97=B0=EB=8F=99=20=ED=96=89=20=EC=8A=AC?= =?UTF-8?q?=EB=A1=AF=C2=B7=EC=97=B0=EA=B2=B0=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EC=9E=AC=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 알림 설정 알림톡 탭에서 각 알림에 카카오 알림톡 템플릿을 연결하는 UI. 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). - 각 알림 행 하단에 범용 확장 슬롯(notification_definition_row_footer) — 연결 상태 줄 + [연결/변경] 버튼. 코어 편집 모달 내부 주입 불가(엔진 modals 미탐색) 회피. - 우리 전용 연결 모달(modals 병합) — 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API(코어 저장 무관). openModal 을 승인 조회보다 먼저 실행. - 상태 배너(readiness 미충족/테스트 모드, 문제 있을 때만) + 알림톡 탭 안내 박스. - SMS 대체발송 안내(실패 시 이 알림 본문이 SMS로 발송) + 다크 모드 solid 색. - 카카오 API 422 는 errorHandling.suppress 로 조용히 처리(전 탭 에러 토스트 제거). Vitest 17 통과, audit 위반 0, Chrome MCP 다크·저장 검수 통과. --- .../extensions/notification_row_footer.json | 101 +++++ .../extensions/notification_tab_core.json | 382 +++++++++++------- .../layouts/notification_tab_binding.test.tsx | 212 ++++++---- .../resources/lang/en.json | 10 +- .../resources/lang/ko.json | 10 +- .../scenarios/alimtalk-dispatch-binding.yaml | 25 +- .../_tab_notification_definitions.json | 9 + 7 files changed, 506 insertions(+), 243 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json new file mode 100644 index 00000000..d873dc8c --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json @@ -0,0 +1,101 @@ +{ + "extension_point": "notification_definition_row_footer", + "comment": "알림 설정 알림톡 탭의 각 알림 행 하단(코어 notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼을 채운다(계획서 §6-2, Phase 6 재설계). channel==='alimtalk'일 때만 노출. extensionPointProps.definition 으로 이 행의 알림(def)을 받고, 연결 정보는 bizppurioBindings(overlay notification_tab_core.json 이 등록·조회) 에서 def.type 으로 읽는다. [연결/변경] → 우리 연결 모달(modal_bizppurio_binding, overlay 가 등록)을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.", + "components": [ + { + "id": "bizppurio_row_binding", + "type": "basic", + "name": "Div", + "if": "{{extensionPointProps.activeChannel === 'alimtalk'}}", + "props": { + "className": "flex-between gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2 min-w-0" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-link", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "comment": "연결됨: 템플릿명(또는 코드) · SMS 대체 표시 / 미연결: '미연결'", + "type": "basic", + "name": "Span", + "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200 truncate" + }, + "text": "{{(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code) + (bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ? ' · SMS' : '')}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code)}}", + "props": { + "className": "text-sm text-gray-400 dark:text-gray-500" + }, + "text": "$t:sirsoft-message_bizppurio.binding.unbound" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 whitespace-nowrap flex-shrink-0" + }, + "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ? '$t:sirsoft-message_bizppurio.binding.btn_change' : '$t:sirsoft-message_bizppurio.binding.btn_connect'}}", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "comment": "모달 상태 seed — 대상 알림 type·이름·변수 + 기존 연결값 프리필", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal": { + "notification_type": "{{extensionPointProps.definition?.type}}", + "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}", + "variables": "{{extensionPointProps.definition?.variables ?? []}}", + "template_code": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ?? ''}}", + "template_name": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name ?? ''}}", + "fallback_sms": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ?? false}}", + "isSaving": false + } + } + }, + { + "handler": "openModal", + "target": "modal_bizppurio_binding" + }, + { + "comment": "승인 템플릿 드롭다운 옵션 조회(모달 열 때만 → 전 탭 에러 없음). 자격증명 미설정 시 422 는 fallback 으로 조용히 처리되며, openModal 뒤에 두어 조회 실패가 모달 표시를 막지 않게 한다.", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioApprovedTemplates" + } + } + ] + } + } + ] + } + ] + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json index c8ced647..e5d0b3d0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -1,13 +1,14 @@ { "target_layout": "admin_settings", - "comment": "알림 템플릿 편집 모달(alimtalk 채널)에 비즈뿌리오 전용 칸을 append 한다(계획서 §6-2, Phase 6). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다(코어 템플릿 무오염). channel==='alimtalk'일 때만 ①안내 카드 ②연결 템플릿 드롭다운 ③SMS 대체 토글을 노출하며, 값을 바꾸면 즉시 우리 API 로 저장하고 toast 로 피드백한다(별도 저장 버튼 없음 — PO 확정 UX). 코어 [저장] 버튼은 본문·수신자만 저장하고 연동과 무관하다. 초기값·저장 후 상태는 bizppurioBindings 데이터소스에서 편집 대상 알림 type 으로 읽는다.", + "comment": "알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(계획서 §6-2, Phase 6 재설계). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼(channel==='alimtalk'일 때만) ③ [연결] 클릭 시 우리 연결 모달(modals 병합)에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API. 편집 모달(본문 편집)과 완전히 분리되어 헷갈리지 않는다.", "data_sources": [ { "id": "bizppurioBindings", + "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", "type": "api", "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", "method": "GET", - "auto_fetch": true, + "auto_fetch": false, "auth_required": true, "fallback": { "data": { @@ -17,11 +18,17 @@ }, { "id": "bizppurioApprovedTemplates", + "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 빈 목록 사용, 안내는 배너·'승인 템플릿 없음' 문구가 담당). 이 토스트를 막지 않으면 모달 열 때마다 에러 토스트가 뜬다.", "type": "api", "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", "method": "GET", - "auto_fetch": true, + "auto_fetch": false, "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, "fallback": { "data": { "templates": [] @@ -33,7 +40,7 @@ { "target_id": "notif_channel_content", "position": "prepend_child", - "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음, PO Q1-A). readiness 미충족 🔴 설정하기 안내 / is_test_mode 🟡 테스트 모드 안내. 코어 목록·모달은 그대로.", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", "components": [ { "id": "bizppurio_status_banner", @@ -41,7 +48,7 @@ "name": "Div", "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", "props": { - "className": "mb-3" + "className": "mt-4 mb-3" }, "children": [ { @@ -128,28 +135,65 @@ ] } ] - } - ] - }, - { - "target_id": "template_variables_info", - "position": "append", - "components": [ + }, { - "id": "bizppurio_alimtalk_section", - "comment": "알림톡 전용 칸 — channel==='alimtalk' 일 때만. 변경 즉시 우리 API 로 저장.", + "id": "bizppurio_alimtalk_guide", + "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", "type": "basic", "name": "Div", - "if": "{{_global.notification_template_form_modal?.template?.channel === 'alimtalk'}}", + "if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}", "props": { - "className": "bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-4 space-y-3" + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } + ] + } + ] + } + ], + "modals": [ + { + "id": "modal_bizppurio_binding", + "comment": "알림톡 연결 전용 모달(우리 소유, 신규). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "id": "bizppurio_binding_modal_body", + "type": "basic", + "name": "Div", + "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", + "props": { + "className": "space-y-4" }, "children": [ { "type": "basic", "name": "Div", "props": { - "className": "flex-start gap-2" + "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" }, "children": [ { @@ -158,35 +202,20 @@ "props": { "name": "fas fa-circle-info", "size": "sm", - "className": "text-teal-600 dark:text-teal-400 flex-shrink-0 mt-0.5" + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" } }, { "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "H4", - "props": { - "className": "text-sm font-medium text-teal-800 dark:text-teal-200" - }, - "text": "$t:sirsoft-message_bizppurio.binding.section_title" - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "text-xs text-teal-700 dark:text-teal-300 mt-0.5" - }, - "text": "$t:sirsoft-message_bizppurio.binding.section_hint" - } - ] + "name": "Span", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" } ] }, { - "id": "bizppurio_binding_template_select", "type": "basic", "name": "Div", "children": [ @@ -202,64 +231,19 @@ "type": "composite", "name": "Select", "props": { - "value": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", + "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", "className": "w-full text-sm", "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" }, "actions": [ { "type": "change", - "handler": "sequence", + "handler": "setState", "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "notification_template_form_modal.bizppurio.template_code": "{{$event.target.value}}", - "notification_template_form_modal.bizppurio.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", - "notification_template_form_modal.bizppurio.fallback_sms": "{{$event.target.value === '' ? false : (_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", - "params": { - "method": "POST", - "body": { - "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", - "template_code": "{{$event.target.value}}", - "template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", - "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioBindings" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.saved", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.save_error", - "type": "error" - } - } - ] - } - ] + "target": "global", + "bizppurio_binding_modal.template_code": "{{$event.target.value}}", + "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" } } ] @@ -276,7 +260,6 @@ ] }, { - "id": "bizppurio_binding_fallback_toggle", "type": "basic", "name": "Div", "props": { @@ -287,61 +270,16 @@ "type": "composite", "name": "Toggle", "props": { - "checked": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false)}}", - "disabled": "{{(_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')) === ''}}" + "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", + "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" }, "actions": [ { "type": "change", - "handler": "sequence", + "handler": "setState", "params": { - "actions": [ - { - "handler": "setState", - "params": { - "target": "global", - "notification_template_form_modal.bizppurio.fallback_sms": "{{!(_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" - } - }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", - "params": { - "method": "POST", - "body": { - "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", - "template_code": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", - "template_name": "{{_global.notification_template_form_modal?.bizppurio?.template_name ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_name ?? '')}}", - "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" - } - }, - "onSuccess": [ - { - "handler": "refetchDataSource", - "params": { - "dataSourceId": "bizppurioBindings" - } - }, - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.saved", - "type": "success" - } - } - ], - "onError": [ - { - "handler": "toast", - "params": { - "message": "$t:sirsoft-message_bizppurio.binding.save_error", - "type": "error" - } - } - ] - } - ] + "target": "global", + "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" } } ] @@ -357,17 +295,177 @@ ] }, { + "id": "bizppurio_binding_fallback_hint", + "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", "type": "basic", "name": "P", "props": { - "className": "text-xs text-teal-700 dark:text-teal-300" + "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" }, - "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" + }, + { + "id": "bizppurio_binding_variables", + "type": "basic", + "name": "Div", + "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap gap-1" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "iteration": { + "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_binding" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.save", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", + "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", + "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_binding" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] } ] } ] } ], + "init_actions": [ + { + "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + } + ], "priority": 320 } \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx index 583baf7f..d179f33a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -1,105 +1,72 @@ /** - * 알림 설정 알림톡 연동 overlay 구조 검증 (Phase 6 재설계, §6-2) + * 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2) * - * notification_tab_core.json 은 코어 admin_settings 편집 모달(alimtalk 채널)에 전용 칸을 얹고, - * 알림톡·SMS 탭 상단에 상태 배너를 얹는다. 코어 편집 모달·저장 버튼은 건드리지 않으며(무오염), - * 전용 칸 값을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음). + * 두 확장 파일로 분리 구현: + * - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 연결 모달(modals) + * + data_sources. target_layout=admin_settings. + * - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯 + * (notification_definition_row_footer)에 연결 상태 줄 + [연결/변경] 버튼. * - * 검증: - * - 데이터소스(bizppurioBindings·bizppurioApprovedTemplates) 주입 - * - 전용 칸: 안내 카드 · 연결 템플릿 드롭다운(즉시 저장) · SMS 대체 토글(연결 없으면 비활성) - * - 상태 배너: readiness 미충족(설정하기) · 테스트 모드 - * - 코어 편집 모달 저장 body 에 bizppurio 필드가 없다(무오염) - * - i18n 키 정합(ko·en 동일) + * 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). 연결은 편집 모달과 분리된 우리 전용 + * 모달에서 하며, 변경 즉시가 아니라 [저장] 버튼으로 명확히 저장한다. 카카오 API 422 는 + * errorHandling.suppress 로 조용히 처리(안내는 배너·문구가 담당). + * + * 검증: 파일 분리(overlay vs extension_point), 배너/안내/모달 구조, 행 슬롯 UI, 저장 배선, + * 무오염(코어 저장 body 미개입), i18n 정합. */ import { describe, it, expect } from 'vitest'; import overlay from '../../../extensions/notification_tab_core.json'; +import footer from '../../../extensions/notification_row_footer.json'; import ko from '../../../lang/ko.json'; import en from '../../../lang/en.json'; import { findById, type AnyNode } from './helpers'; +const overlayRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; +const bannerRoot = { + children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []), +} as AnyNode; +const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode; + /** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */ const collectPluginKeys = (json: unknown): string[] => { const text = JSON.stringify(json); const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; - const keys = [ + return Array.from(new Set([ ...prefixed.map((m) => m.replace('$t:', '')), ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), - ]; - return Array.from(new Set(keys)); + ])); }; -type Overlay = { - target_layout: string; - data_sources?: Array<{ id: string; endpoint: string }>; - injections: Array<{ target_id: string; position: string; components: AnyNode[] }>; -}; - -const ext = overlay as unknown as Overlay; - -/** injection components 를 하나의 검색 가능한 루트로 감싼다. */ -const injectionRoot = (targetId: string): AnyNode => { - const inj = ext.injections.find((i) => i.target_id === targetId); - return { children: inj?.components ?? [] } as AnyNode; -}; - -describe('notification binding overlay — 대상·데이터소스', () => { - it('코어 admin_settings 레이아웃을 대상으로 한다', () => { - expect(ext.target_layout).toBe('admin_settings'); +describe('binding UI — 파일 분리(Overlay vs ExtensionPoint)', () => { + it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => { + expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings'); + expect((overlay as Record).extension_point).toBeUndefined(); }); - it('연동 맵·승인 템플릿 데이터소스를 주입한다', () => { - const ids = (ext.data_sources ?? []).map((d) => d.id); + it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => { + expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer'); + expect((footer as Record).target_layout).toBeUndefined(); + }); + + it('overlay 는 연결 맵·승인 템플릿 데이터소스를 등록한다', () => { + const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); expect(ids).toContain('bizppurioBindings'); expect(ids).toContain('bizppurioApprovedTemplates'); }); - it('전용 칸은 코어 편집 모달의 template_variables_info 뒤에 append 된다', () => { - const inj = ext.injections.find((i) => i.target_id === 'template_variables_info'); - expect(inj).toBeTruthy(); - expect(inj?.position).toBe('append'); + it('승인 템플릿 데이터소스는 auto_fetch:false 이고 422 를 suppress 한다(전 탭 에러 방지)', () => { + const ds = ((overlay as { data_sources?: Array> }).data_sources ?? []) + .find((d) => d.id === 'bizppurioApprovedTemplates'); + expect(ds?.auto_fetch).toBe(false); + expect(JSON.stringify(ds?.errorHandling)).toContain('suppress'); }); }); -describe('notification binding overlay — 전용 칸', () => { - const root = injectionRoot('template_variables_info'); - - it('알림톡 전용 섹션은 channel === alimtalk 일 때만 노출된다', () => { - const section = findById(root, 'bizppurio_alimtalk_section'); - expect(section).toBeTruthy(); - expect((section as { if?: string }).if).toContain("=== 'alimtalk'"); - }); - - it('연결 템플릿 드롭다운을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음)', () => { - const raw = JSON.stringify(findById(root, 'bizppurio_binding_template_select')); - expect(raw).toContain('apiCall'); - expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); - expect(raw).toContain('"method":"POST"'); - expect(raw).toContain('binding.saved'); - }); - - it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이고, 변경 시 즉시 저장한다', () => { - const toggleWrap = findById(root, 'bizppurio_binding_fallback_toggle'); - const raw = JSON.stringify(toggleWrap); - expect(raw).toContain('"disabled"'); - expect(raw).toContain("=== ''"); - expect(raw).toContain('apiCall'); - expect(raw).toContain('"method":"POST"'); - }); - - it('저장 실패 시 에러 토스트를 띄운다', () => { - const raw = JSON.stringify(root); - expect(raw).toContain('binding.save_error'); - }); -}); - -describe('notification binding overlay — 상태 배너', () => { - const root = injectionRoot('notif_channel_content'); - - it('배너는 sms·alimtalk 탭에서 문제가 있을 때만 노출된다', () => { - const banner = findById(root, 'bizppurio_status_banner'); +describe('binding UI — 상태 배너 + 안내 박스', () => { + it('배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => { + const banner = findById(bannerRoot, 'bizppurio_status_banner'); expect(banner).toBeTruthy(); const cond = (banner as { if?: string }).if ?? ''; expect(cond).toContain("'sms'"); @@ -109,34 +76,101 @@ describe('notification binding overlay — 상태 배너', () => { }); it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { - const raw = JSON.stringify(findById(root, 'bizppurio_banner_not_ready')); + const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready')); expect(raw).toContain('banner.not_ready'); expect(raw).toContain('banner.setup_action'); expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); }); - it('테스트 모드 배너는 readiness 충족 + is_test_mode 일 때만 노출된다', () => { - const testBanner = findById(root, 'bizppurio_banner_test_mode'); - const cond = (testBanner as { if?: string }).if ?? ''; - expect(cond).toContain('readiness?.ready !== false'); - expect(cond).toContain('is_test_mode === true'); - expect(JSON.stringify(testBanner)).toContain('banner.test_mode'); + it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => { + const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide'); + expect(guide).toBeTruthy(); + expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); + expect(JSON.stringify(guide)).toContain('binding.list_guide'); }); }); -describe('notification binding overlay — i18n 정합', () => { - it('overlay 가 참조하는 모든 키가 ko·en 에 존재한다', () => { - const keys = collectPluginKeys(overlay); - expect(keys.length).toBeGreaterThan(0); +describe('binding UI — 행 연결(extension_point)', () => { + it('행 연결 UI 는 channel === alimtalk 일 때만 노출된다', () => { + const row = findById(footerRoot, 'bizppurio_row_binding'); + expect(row).toBeTruthy(); + expect((row as { if?: string }).if).toContain("extensionPointProps.activeChannel === 'alimtalk'"); + }); - // 플러그인 resources/lang/{ko,en}.json 파일 루트가 곧 sirsoft-message_bizppurio 네임스페이스. - // 키 `sirsoft-message_bizppurio.binding.saved` → 첫 세그먼트(네임스페이스) 제거 후 파일 루트에서 해석. + it('연결 상태를 bizppurioBindings 에서 def.type 으로 읽어 표시한다(연결됨/미연결)', () => { + const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); + expect(raw).toContain('bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]'); + expect(raw).toContain('binding.unbound'); + expect(raw).toContain('binding.btn_connect'); + expect(raw).toContain('binding.btn_change'); + }); + + it('[연결] 클릭 시 모달 상태를 seed 하고 우리 연결 모달을 연다', () => { + const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); + expect(raw).toContain('bizppurio_binding_modal'); + expect(raw).toContain('"openModal"'); + expect(raw).toContain('modal_bizppurio_binding'); + }); + + it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => { + const row = findById(footerRoot, 'bizppurio_row_binding'); + const raw = JSON.stringify(row); + const openIdx = raw.indexOf('"openModal"'); + const refetchIdx = raw.indexOf('bizppurioApprovedTemplates'); + expect(openIdx).toBeGreaterThan(-1); + expect(refetchIdx).toBeGreaterThan(-1); + expect(openIdx).toBeLessThan(refetchIdx); + }); +}); + +describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 분리)', () => { + const modal = findById(overlayRoot, 'modal_bizppurio_binding'); + + it('연결 전용 모달이 modals 로 등록된다', () => { + expect(modal).toBeTruthy(); + expect((modal as { name?: string }).name).toBe('Modal'); + }); + + it('안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 토글 + 변수 안내를 담는다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain('binding.section_hint'); + expect(raw).toContain('binding.connected_template'); + expect(raw).toContain('binding.fallback_sms'); + expect(raw).toContain('binding.variables_hint'); + }); + + it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이다', () => { + const raw = JSON.stringify(findById(overlayRoot, 'bizppurio_binding_modal_body')); + expect(raw).toContain('"disabled"'); + expect(raw).toContain("=== ''"); + }); + + it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => { + const raw = JSON.stringify(modal); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('binding.saved'); + expect(raw).toContain('binding.save_error'); + expect(raw).toContain('"closeModal"'); + expect(raw).toContain('bizppurioBindings'); + }); +}); + +describe('binding UI — 코어 무오염 + i18n', () => { + it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { + const all = JSON.stringify(overlay) + JSON.stringify(footer); + expect(all).not.toContain('/api/admin/notification-templates/'); + expect(all).not.toContain('notification_template_form_modal'); + }); + + it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => { + const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)]; + expect(keys.length).toBeGreaterThan(0); const resolve = (root: unknown, path: string): unknown => path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); - - for (const key of keys) { + for (const key of Array.from(new Set(keys))) { expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); } }); -}); \ No newline at end of file +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index d6a7f6e9..25622ee0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -64,12 +64,18 @@ }, "binding": { "section_title": "KakaoTalk Alimtalk Binding", - "section_hint": "Changes are saved instantly, separately from the message body.", + "list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.", + "section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.", + "modal_title": "Alimtalk Binding · {name}", + "unbound": "Not connected", + "btn_connect": "Connect", + "btn_change": "Change", "connected_template": "Connected Template", "none": "Not connected", "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", "fallback_sms": "Fall back to SMS on failure", - "variables_hint": "Available variables are auto-substituted on send.", + "fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.", + "variables_hint": "Available variables (auto-substituted on send)", "saved": "Alimtalk binding saved.", "save_error": "Failed to save alimtalk binding." }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 024fd0ed..e38085a6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -64,12 +64,18 @@ }, "binding": { "section_title": "카카오 알림톡 연동", - "section_hint": "선택하면 즉시 저장됩니다. 본문 저장과 별개로 동작합니다.", + "list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.", + "section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.", + "modal_title": "알림톡 연결 · {name}", + "unbound": "미연결", + "btn_connect": "연결", + "btn_change": "연결 변경", "connected_template": "연결 템플릿", "none": "연결 안 함", "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", "fallback_sms": "실패 시 SMS로 대체발송", - "variables_hint": "제공 변수는 발송 시 자동 치환됩니다.", + "fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.", + "variables_hint": "제공 변수 (발송 시 자동 치환)", "saved": "알림톡 연동을 저장했습니다.", "save_error": "알림톡 연동 저장에 실패했습니다." }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml index ff224438..9d071d93 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml @@ -27,9 +27,13 @@ description: | - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. - 변수 치환: 코어 {var} → 알림톡 #{var}(D8, 매핑 UI 없음). - 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합. - - 전용 칸(overlay): channel==='alimtalk' 일 때만 안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 - 토글. 변경 즉시 우리 API(store) 저장 + toast. 승인(RDY/ACT) 템플릿만 드롭다운 노출. - - 상태 배너(overlay): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡테스트모드. + - 행 연결 UI(extension_point notification_definition_row_footer): 알림톡 탭의 각 알림 행 하단에 + 연결 상태 줄(템플릿명·SMS대체) + [연결/변경] 버튼. channel==='alimtalk' 일 때만. + - 연결 모달(overlay modals, 우리 소유): 코어 편집 모달과 분리. 안내 카드 + 연결 템플릿 + 드롭다운(승인 RDY/ACT만) + SMS 대체 토글 + 변수 안내 + [취소][저장]. [저장]→우리 API(store) + + toast + 모달 닫힘 + 목록 갱신. 카카오 API 422 는 errorHandling.suppress 로 조용히 처리. + - 상태 배너(overlay injections): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡. + - 안내 박스(overlay): 알림톡 탭 상시 안내(무엇을 하는 화면인지). axes: channel: [alimtalk] @@ -75,15 +79,20 @@ effects: - bindings_map_returned_for_prefill - view_permission_required_for_read - manage_permission_required_for_store - # 전용 칸·배너 (프론트 — Vitest 구조/i18n) - - alimtalk_section_shown_only_for_alimtalk_channel - - template_select_saves_immediately_via_our_api + # 행 연결 UI·연결 모달·배너 (프론트 — Vitest 구조/i18n) + - row_binding_shown_only_for_alimtalk_channel + - row_shows_bound_or_unbound_status_from_bindings + - connect_button_seeds_modal_and_opens_our_modal + - openmodal_runs_before_approved_refetch + - our_binding_modal_registered_via_modals - fallback_toggle_disabled_when_no_template - - save_error_shows_toast + - save_posts_to_our_api_then_toast_close_refresh + - approved_templates_422_suppressed - banner_shown_only_on_sms_alimtalk_tab_when_problem - not_ready_banner_has_setup_action - test_mode_banner_shown_when_ready_and_test_mode - - core_edit_modal_save_body_has_no_bizppurio_fields + - alimtalk_tab_guide_box_shown + - core_edit_modal_untouched_no_bizppurio_fields # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상) - e2e_open_alimtalk_edit_shows_section - e2e_select_template_saves_and_toasts diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json index e9355a2d..9b77bf77 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json @@ -500,6 +500,15 @@ ] } ] + }, + { + "comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.", + "type": "extension_point", + "name": "notification_definition_row_footer", + "props": { + "definition": "{{def}}", + "activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}" + } } ] }, From fc00e30fc483c684269f8af69aad757480898702 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 20 Jul 2026 15:30:06 +0900 Subject: [PATCH 09/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=82=AC=EC=9C=A0=20=EB=85=B8=EC=B6=9C=20+=20?= =?UTF-8?q?=ED=83=AD=20=EC=A0=84=ED=99=98=C2=B7=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오 kapi HTTP 실패 시 응답 body의 message/code를 예외에 실어 운영자에게 실제 사유(IP 차단·반려 등)를 노출 (기존 고정 문구로 뭉개지던 문제 해소) - 설정 탭 전환을 replaceUrl→navigate(replace)로 교체 (URL만 바뀌고 화면이 전환되지 않던 문제 해소) - 템플릿 목록 조회 실패 시 카카오 사유를 빨간 배너로 표시 - report_url 데이터소스 if 조건으로 templates 탭 불필요 재조회 억제 --- .../layouts/plugin_settings.test.tsx | 56 ++++++++++++++++++ .../resources/lang/en.json | 3 + .../resources/lang/ko.json | 3 + .../layouts/admin/plugin_settings.json | 59 ++++++++++++++++--- .../src/Services/BizppurioKakaoApiClient.php | 36 ++++++++--- .../Services/BizppurioKakaoApiClientTest.php | 36 +++++++++++ .../tests/scenarios/plugin-settings.yaml | 3 + 7 files changed, 179 insertions(+), 17 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index 7f63768d..b233e1bb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -188,3 +188,59 @@ describe('plugin_settings.json — i18n 키 정합', () => { } }); }); + +describe('plugin_settings.json — 탭 전환', () => { + // 탭 버튼은 query.tab 을 바꾸며 화면(if 조건부 패널)을 다시 그려야 하므로 + // replaceUrl(URL만 변경, if 재평가 없음) 이 아니라 navigate 를 써야 한다. + const tabButtonHandlers = (id: string): string[] => { + const btn = findById(layout, id); + const handlers: string[] = []; + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') return; + const n = node as Record; + if (typeof n.handler === 'string') handlers.push(n.handler); + for (const v of Object.values(n)) { + if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === 'object') walk(v); + } + }; + walk((btn as { actions?: unknown })?.actions); + return handlers; + }; + + it('환경설정 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => { + const handlers = tabButtonHandlers('tab_connection'); + expect(handlers).toContain('navigate'); + expect(handlers).not.toContain('replaceUrl'); + }); + + it('알림톡 템플릿 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => { + const handlers = tabButtonHandlers('tab_templates'); + expect(handlers).toContain('navigate'); + expect(handlers).not.toContain('replaceUrl'); + }); +}); + +describe('plugin_settings.json — 목록 조회 실패 표시', () => { + it('alimtalk_templates 데이터소스가 실패 사유를 _local.templateListError 에 담는다', () => { + const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; + const ds = sources.find((s) => s.id === 'alimtalk_templates') as + | Record + | undefined; + expect(ds).toBeTruthy(); + const onError = JSON.stringify(ds?.onError ?? {}); + // 카카오가 준 사유(kakao_message)를 우선 노출, 없으면 error.message 폴백 + expect(onError).toContain('templateListError'); + expect(onError).toContain('kakao_message'); + }); + + it('목록 오류 배너가 templateListError 조건으로 존재한다', () => { + const banner = findById(layout, 'templates_list_error') as + | Record + | undefined; + expect(banner).toBeTruthy(); + expect(banner?.if).toBe('{{_local.templateListError}}'); + // 사유 본문을 그대로 렌더한다 + expect(JSON.stringify(banner)).toContain('{{_local.templateListError}}'); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index 25622ee0..a1d65e73 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -95,6 +95,9 @@ "sender_key_missing": "Alimtalk sender profile key", "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao." }, + "list_error": { + "title": "Failed to load the template list" + }, "list": { "new": "New Template", "refresh": "Refresh", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index e38085a6..42137dc1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -95,6 +95,9 @@ "sender_key_missing": "알림톡 발신프로필 키", "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다." }, + "list_error": { + "title": "템플릿 목록을 불러오지 못했습니다" + }, "list": { "new": "새 템플릿", "refresh": "새로고침", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index 317f819a..52175757 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -34,7 +34,8 @@ "method": "GET", "auto_fetch": true, "auth_required": true, - "initLocal": "reportInfo" + "initLocal": "reportInfo", + "if": "{{(query.tab ?? 'connection') === 'connection'}}" }, { "id": "alimtalk_templates", @@ -46,6 +47,14 @@ "params": { "status": "{{_local.templateStatus ?? ''}}", "keyword": "{{_local.templateKeyword ?? ''}}" + }, + "onSuccess": { + "handler": "setState", + "params": { "target": "local", "templateListError": null } + }, + "onError": { + "handler": "setState", + "params": { "target": "local", "templateListError": "{{error.errors?.kakao_message ?? error.message}}" } } }, { @@ -95,8 +104,8 @@ "actions": [ { "type": "click", - "handler": "replaceUrl", - "params": { "query": { "tab": "connection" }, "mergeQuery": true } + "handler": "navigate", + "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": true } } ], "children": [ @@ -116,14 +125,14 @@ "type": "click", "handler": "sequence", "actions": [ - { - "handler": "replaceUrl", - "params": { "query": { "tab": "templates" }, "mergeQuery": true } - }, { "handler": "setState", "params": { "target": "local", "templateView": "list" } }, + { + "handler": "navigate", + "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "templates" }, "mergeQuery": true, "replace": true } + }, { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } @@ -150,7 +159,7 @@ "type": "basic", "name": "Div", "props": { - "className": "alert-info" + "className": "mt-6 alert-info" }, "children": [ { @@ -919,6 +928,38 @@ "if": "{{(query.tab ?? 'connection') === 'templates'}}", "props": { "className": "mt-6" }, "children": [ + { + "id": "templates_list_error", + "type": "basic", + "name": "Div", + "if": "{{_local.templateListError}}", + "props": { "className": "mb-4 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950 p-4 flex gap-2 items-start" }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { "name": "circle-exclamation", "size": "md", "className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "P", + "props": { "className": "text-sm font-medium text-red-800 dark:text-red-200" }, + "text": "$t:sirsoft-message_bizppurio.templates.list_error.title" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "text-xs text-red-700 dark:text-red-300 mt-1 leading-relaxed" }, + "text": "{{_local.templateListError}}" + } + ] + } + ] + }, { "id": "templates_readiness", "type": "basic", @@ -981,7 +1022,7 @@ "name": "Button", "props": { "type": "button", "className": "btn btn-secondary btn-sm whitespace-nowrap" }, "actions": [ - { "type": "click", "handler": "replaceUrl", "params": { "query": { "tab": "connection" }, "mergeQuery": true } } + { "type": "click", "handler": "navigate", "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": true } } ], "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.readiness.go_settings" } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php index 7edad0f8..53cf7dc8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -6,6 +6,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Services; use App\Services\PluginSettingsService; use Illuminate\Http\Client\PendingRequest; +use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Http; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; @@ -111,10 +112,7 @@ class BizppurioKakaoApiClient ]); if ($response->failed()) { - throw new BizppurioApiException( - __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), - httpStatus: $response->status(), - ); + throw $this->failureException($response); } $result = $response->json(); @@ -178,10 +176,7 @@ class BizppurioKakaoApiClient ])); if ($response->failed()) { - throw new BizppurioApiException( - __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), - httpStatus: $response->status(), - ); + throw $this->failureException($response); } $result = $response->json(); @@ -196,6 +191,31 @@ class BizppurioKakaoApiClient return $result; } + /** + * HTTP 실패 응답에서 카카오가 준 사유(message)와 결과코드(code)를 추출해 예외를 만듭니다. + * + * 카카오 관리 API 는 실패 시에도 `{code, message}` 봉투를 반환하므로, body 를 파싱해 + * 운영자에게 실제 사유(접근 불가 IP·반려 사유·계정 오류 등)를 노출한다. body 파싱이 + * 불가하거나 message 가 비어 있으면 일반 실패 문구로 폴백한다. + * + * @param Response $response 실패한 HTTP 응답 + * @return BizppurioApiException 카카오 사유·결과코드·HTTP 상태가 담긴 예외 + */ + private function failureException(Response $response): BizppurioApiException + { + $body = $response->json(); + $message = is_array($body) ? (string) ($body['message'] ?? '') : ''; + $code = is_array($body) ? (string) ($body['code'] ?? '') : ''; + + return new BizppurioApiException( + $message !== '' + ? $message + : __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + resultCode: $code !== '' ? $code : null, + httpStatus: $response->status(), + ); + } + /** * 환경설정에서 bizId(=bizppurio_id)와 apiKey 를 조회합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php index 347fa5c0..795b27ad 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -134,6 +134,42 @@ class BizppurioKakaoApiClientTest extends PluginTestCase $this->client()->getSenderProfiles(); } + public function test_http_실패시_카카오_message와_code를_예외에_싣는다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response( + ['code' => '403', 'message' => '접근할 수 없는 IP 입니다. (114.207.113.206)'], + 403, + ), + ]); + + try { + $this->client()->getSenderProfiles(); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('접근할 수 없는 IP 입니다. (114.207.113.206)', $e->getMessage()); + $this->assertSame('403', $e->getResultCode()); + $this->assertSame(403, $e->getHttpStatus()); + } + } + + public function test_http_실패_body에_message가_없으면_일반문구로_폴백한다(): void + { + Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]); + + try { + $this->client()->getSenderProfiles(); + $this->fail('BizppurioApiException 이 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame( + __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'), + $e->getMessage(), + ); + $this->assertNull($e->getResultCode()); + $this->assertSame(500, $e->getHttpStatus()); + } + } + protected function tearDown(): void { Mockery::close(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml index 02ae94e0..d1fc3093 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -62,6 +62,9 @@ effects: - save_button_disabled_without_changes - save_puts_to_core_plugin_settings_api - only_registered_handlers_used + # 탭 전환 (환경설정 ↔ 알림톡 템플릿) — query.tab 변경 시 if 조건부 패널 재평가 필요 + - tab_buttons_use_navigate_not_replaceurl # replaceUrl 은 URL만 바꿔 if 재평가 없음 → 화면 미전환 회귀 + - alimtalk_template_list_error_shown_with_kakao_reason # 목록 조회 실패 시 카카오 사유(kakao_message)를 배너로 노출 # 다국어 - all_referenced_i18n_keys_exist_in_ko_and_en # 설치·활성화 (백엔드) From 11f1d79540c55db72ffc0531fb1d06448374efbf Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 20 Jul 2026 16:20:07 +0900 Subject: [PATCH 10/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=C2=B7=EC=95=8C=EB=A6=BC=ED=86=A1=20=EA=B2=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발송 결과(성공/실패·사유·잔액부족·SMS 대체발송)를 코어 "알림 발송 이력" 화면의 각 행에 표시한다. 코어 앱·코어 테이블 무수정 — 연결 표식은 bizppurio_dispatches.notification_log_id 에만 두고, 발송 사이클의 코어 after_log_sent/after_log_failed 훅으로 그 로그 id 를 연결한다. - 결과 조회는 파라미터 없는 GET /dispatch-results/recent 로 최근 결과 맵을 받아 row.id 매칭 (목록 로드 순서에 무관 — 빈 배열 전송·타이밍으로 결과가 비던 문제 해소). - 코어 이력 datagrid 에 결과 컬럼을 layout_extensions 로 주입하고, 행 토글에도 상세 결과를 얹는다. 메일·사이트내알림 탭에선 컬럼(헤더 포함)을 숨긴다. 다크모드 배지 색 보강. - 브라우저 검수용 데모 시더(4상태·엣지 케이스 21건) + 전 계층 테스트(백엔드·Vitest 렌더) + 시나리오 매니페스트 + API 레퍼런스 동반. --- docs/backend/api/README.md | 4 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 3 +- ...0001_create_bizppurio_dispatches_table.php | 2 + .../seeders/DispatchResultDemoSeeder.php | 184 ++++++++++ .../docs/api/README.md | 3 +- .../docs/api/dispatch-results.md | 328 ++++++++++++++++++ .../docs/api/notification-bindings.md | 2 - .../sirsoft-message_bizppurio/plugin.php | 3 + .../extensions/notification_log_result.json | 175 ++++++++++ .../layouts/notification_log_result.test.tsx | 190 ++++++++++ .../resources/js/__tests__/setup.ts | 25 ++ .../resources/lang/en.json | 12 + .../resources/lang/ko.json | 12 + .../Admin/DispatchResultController.php | 61 ++++ .../Requests/DispatchResultLookupRequest.php | 52 +++ .../Requests/StoreAlimtalkTemplateRequest.php | 2 + .../StoreNotificationBindingRequest.php | 2 + .../Requests/UploadTemplateImageRequest.php | 2 + .../Listeners/LinkNotificationLogListener.php | 90 +++++ .../src/Models/BizppurioDispatch.php | 12 +- .../MessageBizppurioServiceProvider.php | 15 + .../BizppurioDispatchRepository.php | 56 +++ .../BizppurioDispatchRepositoryInterface.php | 37 ++ .../src/Services/AlimtalkChannelDriver.php | 6 + .../src/Services/DispatchLinkContext.php | 55 +++ .../src/Services/DispatchResultService.php | 97 ++++++ .../src/Services/SmsChannelDriver.php | 6 + .../src/routes/api.php | 21 ++ .../DispatchResultEndpointTest.php | 228 ++++++++++++ .../Notification/NotificationLogLinkTest.php | 159 +++++++++ .../BizppurioDispatchRepositoryTest.php | 60 ++++ .../Services/AlimtalkChannelDriverTest.php | 2 + .../Services/DispatchResultServiceTest.php | 113 ++++++ .../Unit/Services/SmsChannelDriverTest.php | 4 +- .../scenarios/dispatch-result-injection.yaml | 91 +++++ .../vitest.config.ts | 3 +- 36 files changed, 2105 insertions(+), 12 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 504fdee8..28077d63 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 391 +- **확장 수**: 10 · **엔드포인트 수**: 393 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,7 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 4 / 19 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 5 / 21 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index aa2082ce..0ea37664 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -16,7 +16,8 @@ - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) -- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.) +- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). +- 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다. - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. - 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다. - 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php index b9bfb832..fd029852 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php @@ -33,6 +33,7 @@ return new class extends Migration ->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete(); $table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)'); $table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null'); + $table->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다'); $table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)'); $table->string('result_code', 10)->nullable()->comment('결과 코드 (발송응답 or 리포트)'); $table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)'); @@ -50,6 +51,7 @@ return new class extends Migration $table->index('sent_at', 'bizppurio_dispatch_sent_at_idx'); $table->index('to_user_id', 'bizppurio_dispatch_user_idx'); $table->index(['status', 'sent_at'], 'bizppurio_dispatch_status_sent_idx'); + $table->index('notification_log_id', 'bizppurio_dispatch_notif_log_idx'); }); if (DB::getDriverName() === 'mysql') { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php b/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php new file mode 100644 index 00000000..0d47269b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php @@ -0,0 +1,184 @@ +clear(); + + $bizppurioCases = $this->bizppurioCases(); + $nonBizppurioCases = $this->nonBizppurioCases(); + + foreach ($bizppurioCases as $case) { + $log = $this->createLog($case, hasDispatch: true); + $this->createDispatch($case, $log->id); + } + + foreach ($nonBizppurioCases as $case) { + // 비-비즈뿌리오(mail/database)는 dispatch 를 만들지 않는다(결과 매칭 없음 확인용). + $this->createLog($case, hasDispatch: false); + } + + $total = count($bizppurioCases) + count($nonBizppurioCases); + $this->command?->info('[A-2 데모] 코어 알림 로그 '.$total.'건 삽입 완료 (비즈뿌리오 '.count($bizppurioCases).' + 비-비즈뿌리오 '.count($nonBizppurioCases).').'); + $this->command?->info('[A-2 데모] 전체/SMS/알림톡 탭 = 결과 컬럼 노출, 메일/사이트내알림 탭 = 결과 컬럼(헤더 포함) 숨김을 확인하세요.'); + $this->command?->info('[A-2 데모] 정리: DispatchResultDemoSeeder::clear() — source="'.self::SOURCE_MARKER.'" 로그와 연결 dispatch 만 삭제됩니다.'); + } + + /** + * 비즈뿌리오 발송(sms/lms/alimtalk) 케이스 — 4가지 상태 + 엣지 케이스 망라. + * + * @return array> + */ + private function bizppurioCases(): array + { + return [ + // ── SMS ──────────────────────────────────────────────────────────── + ['channel' => 'sms', 'type' => 'welcome', 'name' => '홍길동', 'phone' => '01011110001', 'member' => true, 'status' => 'success', 'code' => '4100', 'fallback' => null], + ['channel' => 'sms', 'type' => 'order_shipped', 'name' => '김철수', 'phone' => '01011110002', 'member' => true, 'status' => 'failed', 'code' => '4400', 'fallback' => null], // 음영 지역 + ['channel' => 'sms', 'type' => 'reset_password', 'name' => '이수정', 'phone' => '01011110003', 'member' => true, 'status' => 'failed', 'code' => '4410', 'fallback' => null], // 잘못된 번호 + ['channel' => 'sms', 'type' => 'order_confirmed', 'name' => '박영호', 'phone' => '01011110004', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], // 발송요청 성공, 리포트 대기(코드 없음) + ['channel' => 'sms', 'type' => 'welcome', 'name' => '정지원', 'phone' => '01011110005', 'member' => true, 'status' => 'pending', 'code' => null, 'fallback' => null], // 발송중(pending) + ['channel' => 'sms', 'type' => 'order_confirmed', 'name' => null, 'phone' => '01011110006', 'member' => false, 'status' => 'success', 'code' => '4100', 'fallback' => null], // 비회원(수신자 링크 없음) + ['channel' => 'sms', 'type' => 'order_cancelled', 'name' => '최민아', 'phone' => '01011110007', 'member' => true, 'status' => 'failed', 'code' => '9070', 'fallback' => null], // 잔액 부족(문자) + ['channel' => 'sms', 'type' => 'order_shipped', 'name' => '강대현', 'phone' => '01011110008', 'member' => true, 'status' => 'failed', 'code' => '9999', 'fallback' => null], // 미정의 코드(사유 없이 코드만) + + // ── LMS ──────────────────────────────────────────────────────────── + ['channel' => 'lms', 'type' => 'order_confirmed', 'name' => '윤서연', 'phone' => '01011110009', 'member' => true, 'status' => 'success', 'code' => '6600', 'fallback' => null], // LMS 성공 + ['channel' => 'lms', 'type' => 'order_delivered', 'name' => '임재훈', 'phone' => '01011110010', 'member' => true, 'status' => 'failed', 'code' => '6603', 'fallback' => null], // LMS 음영 지역 + + // ── 알림톡 ────────────────────────────────────────────────────────── + ['channel' => 'alimtalk', 'type' => 'order_confirmed', 'name' => '한지민', 'phone' => '01011110011', 'member' => true, 'status' => 'success', 'code' => '7000', 'fallback' => null], // 알림톡 성공 + ['channel' => 'alimtalk', 'type' => 'reset_password', 'name' => '오세훈', 'phone' => '01011110012', 'member' => true, 'status' => 'success', 'code' => '7000', 'fallback' => '성공'], // 대체발송 성공 + ['channel' => 'alimtalk', 'type' => 'order_shipped', 'name' => '신유진', 'phone' => '01011110013', 'member' => true, 'status' => 'failed', 'code' => '7436', 'fallback' => '실패'], // 잔액 부족 + 대체발송 실패 + ['channel' => 'alimtalk', 'type' => 'welcome', 'name' => '배준영', 'phone' => '01011110014', 'member' => true, 'status' => 'failed', 'code' => '7206', 'fallback' => null], // 검수 안 된 템플릿 + ['channel' => 'alimtalk', 'type' => 'order_confirmed', 'name' => null, 'phone' => '01011110015', 'member' => false, 'status' => 'success', 'code' => '7000', 'fallback' => null], // 비회원 알림톡 + ['channel' => 'alimtalk', 'type' => 'password_changed', 'name' => '문가영', 'phone' => '01011110016', 'member' => true, 'status' => 'pending', 'code' => null, 'fallback' => null], // 알림톡 발송중 + ]; + } + + /** + * 비-비즈뿌리오 발송(mail/database) 케이스 — 결과 컬럼 숨김 확인용(dispatch 없음). + * + * @return array> + */ + private function nonBizppurioCases(): array + { + return [ + ['channel' => 'mail', 'type' => 'welcome', 'name' => '홍보람', 'phone' => 'boram@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], + ['channel' => 'mail', 'type' => 'reset_password', 'name' => '서다인', 'phone' => 'dain@example.com', 'member' => true, 'status' => 'failed', 'code' => null, 'fallback' => null], + ['channel' => 'mail', 'type' => 'password_changed', 'name' => '남기훈', 'phone' => 'gihun@example.com', 'member' => true, 'status' => 'skipped', 'code' => null, 'fallback' => null], + ['channel' => 'database', 'type' => 'new_comment', 'name' => '조은채', 'phone' => 'eunchae@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], + ['channel' => 'database', 'type' => 'order_confirmed', 'name' => '권도윤', 'phone' => 'doyoon@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], + ]; + } + + /** + * 코어 알림 로그 1건을 생성합니다. + * + * @param array $case 케이스 정의 + * @param bool $hasDispatch 비즈뿌리오 발송 여부(로그 본문 문구용) + * @return NotificationLog 생성된 로그 + */ + private function createLog(array $case, bool $hasDispatch): NotificationLog + { + // 코어 로그 status 는 sent/failed/skipped 만 있으므로, dispatch 의 pending/sent/success 는 sent 로 맵. + $logStatus = match ($case['status']) { + 'failed' => 'failed', + 'skipped' => 'skipped', + default => 'sent', + }; + + return NotificationLog::create([ + 'channel' => $case['channel'], + 'notification_type' => $case['type'], + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => $case['phone'], + 'recipient_name' => $case['name'], + 'subject' => $case['channel'] === 'mail' ? '[A-2 데모] '.$case['type'].' 메일' : null, + 'body' => '[A-2 데모] '.$case['type'].' 발송 본문 샘플'.($hasDispatch ? ' (비즈뿌리오)' : ''), + 'status' => $logStatus, + 'source' => self::SOURCE_MARKER, + 'sent_at' => now(), + ]); + } + + /** + * 코어 로그에 연결된 비즈뿌리오 dispatch 1건을 생성합니다. + * + * @param array $case 케이스 정의 + * @param int $notificationLogId 연결할 코어 로그 id + */ + private function createDispatch(array $case, int $notificationLogId): void + { + BizppurioDispatch::create([ + 'refkey' => 'demo_'.Str::random(26), + 'channel' => $case['channel'], + 'to_number' => $case['phone'], + 'to_name' => $case['name'], + 'to_user_id' => null, // 데모는 실제 회원 FK 연결 없음(회원/비회원 구분은 코어 로그 recipient 로 표현) + 'content' => '[A-2 데모] 발송 본문 샘플', + 'notification_type' => $case['type'], + 'notification_log_id' => $notificationLogId, + 'status' => $case['status'], + 'result_code' => $case['code'], + 'result_message' => null, + 'fallback_status' => $case['fallback'], + 'source' => 'auto', + 'sent_at' => now(), + 'reported_at' => in_array($case['status'], ['success', 'failed'], true) ? now() : null, + ]); + } + + /** + * 데모 삽입분만 삭제합니다 (source 마커 기준). + * + * 코어 로그를 먼저 찾아 그 id 로 연결된 dispatch 를 지운 뒤 로그를 지운다. 실제 발송 데이터는 + * 이 마커를 갖지 않으므로 영향받지 않는다. + */ + public function clear(): void + { + $logIds = NotificationLog::query()->where('source', self::SOURCE_MARKER)->pluck('id')->all(); + + if ($logIds !== []) { + BizppurioDispatch::query()->whereIn('notification_log_id', $logIds)->delete(); + NotificationLog::query()->whereIn('id', $logIds)->delete(); + } + + $this->command?->info('[A-2 데모] 데모 로그·dispatch '.count($logIds).'건을 정리했습니다.'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index cf935df5..e6cf7927 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,11 +4,12 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 4 · **엔드포인트 수**: 19 +- **문서 수**: 5 · **엔드포인트 수**: 21 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | | [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 | +| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 | | [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 | | [report.md](report.md) | `report` | 1 | | [webhook.md](webhook.md) | `webhook` | 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md new file mode 100644 index 00000000..530e1c45 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md @@ -0,0 +1,328 @@ +# Dispatch Results API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Dispatch Results 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@lookup` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification_log_ids | body | array | 아니오 | max 100 | notification log 식별자 배열 | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "notification_log_ids": [ + "예시값" + ] +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| results | array | `[]` | | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "results": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(layout_extensions overlay)이 소비하는 조회 API 다. +현재 페이지에 표시된 코어 알림 로그 id 배열(`notification_log_ids`, 최대 100)을 넘기면, 그 로그에 +연결된 비즈뿌리오 발송 결과를 **로그 id 를 키로 하는 맵**으로 한 번에 돌려준다(행마다 개별 호출하지 +않아 N+1 을 피한다). 코어 알림 로그 테이블은 수정하지 않으며, 연결 표식은 비즈뿌리오 쪽 +(`bizppurio_dispatches.notification_log_id`)에만 둔다. + +매칭되지 않는 로그 id(메일·사이트내알림 등 비-비즈뿌리오 발송)는 결과 맵에서 제외된다 — 화면에서는 +그 행의 결과 컬럼이 빈 셀이 된다. 결과에는 전화번호 등 민감정보를 포함하지 않는다(결과 컬럼은 상태· +사유만 표시). + +**응답 필드** (`data.results` — 키는 `notification_log_id`, 값은 결과 객체) + +| 필드 | 타입 | 용도 | +| --- | --- | --- | +| status | string\|null | 발송 상태: `sent` / `success` / `failed` (DispatchStatus) | +| status_label | string\|null | 로케일 상태 라벨 (예: "성공", "실패", "발송중") | +| result_code | string\|null | 결과 코드 (발송응답 또는 webhook 리포트). 리포트 미수신 시 null | +| result_label | string\|null | `사유 (코드)` 표시 라벨 (예: "음영 지역 (4400)"). 코드 없으면 null | +| is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 | +| fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null | +| channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` | + +**응답 예시** + +```json +{ + "success": true, + "message": "성공", + "data": { + "results": { + "128": { + "status": "failed", + "status_label": "실패", + "result_code": "4400", + "result_label": "음영 지역 (4400)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + } + } + } +} +``` + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@recent` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "results": { + "21": { + "status": "pending", + "status_label": "대기", + "result_code": null, + "result_label": null, + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "20": { + "status": "success", + "status_label": "성공", + "result_code": "7000", + "result_label": "성공 (7000)", + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "19": { + "status": "failed", + "status_label": "실패", + "result_code": "7206", + "result_label": "검수되지 않은 템플릿 (7206)", + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "18": { + "status": "failed", + "status_label": "실패", + "result_code": "7436", + "result_label": "지갑 잔액 부족(알림톡) (7436)", + "is_low_balance": true, + "fallback_status": "실패", + "channel": "alimtalk" + }, + "17": { + "status": "success", + "status_label": "성공", + "result_code": "7000", + "result_label": "성공 (7000)", + "is_low_balance": false, + "fallback_status": "성공", + "channel": "alimtalk" + }, + "16": { + "status": "success", + "status_label": "성공", + "result_code": "7000", + "result_label": "성공 (7000)", + "is_low_balance": false, + "fallback_status": null, + "channel": "alimtalk" + }, + "15": { + "status": "failed", + "status_label": "실패", + "result_code": "6603", + "result_label": "음영 지역 (6603)", + "is_low_balance": false, + "fallback_status": null, + "channel": "lms" + }, + "14": { + "status": "success", + "status_label": "성공", + "result_code": "6600", + "result_label": "성공 (6600)", + "is_low_balance": false, + "fallback_status": null, + "channel": "lms" + }, + "13": { + "status": "failed", + "status_label": "실패", + "result_code": "9999", + "result_label": "9999", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "12": { + "status": "failed", + "status_label": "실패", + "result_code": "9070", + "result_label": "잔액 부족(문자) (9070)", + "is_low_balance": true, + "fallback_status": null, + "channel": "sms" + }, + "11": { + "status": "success", + "status_label": "성공", + "result_code": "4100", + "result_label": "성공 (4100)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "10": { + "status": "pending", + "status_label": "대기", + "result_code": null, + "result_label": null, + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "9": { + "status": "sent", + "status_label": "발송중", + "result_code": null, + "result_label": null, + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "8": { + "status": "failed", + "status_label": "실패", + "result_code": "4410", + "result_label": "잘못된 번호 (4410)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "7": { + "status": "failed", + "status_label": "실패", + "result_code": "4400", + "result_label": "음영 지역 (4400)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + }, + "6": { + "status": "success", + "status_label": "성공", + "result_code": "4100", + "result_label": "성공 (4100)", + "is_low_balance": false, + "fallback_status": null, + "channel": "sms" + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md index f294271e..253fb224 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -2,8 +2,6 @@ > **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. -> **용도**: 코어 알림 편집 모달(alimtalk 채널)에 얹힌 전용 칸이 소비하는 조회·저장 API. 연결 템플릿 드롭다운/SMS 대체 토글을 바꾸면 즉시 `store` 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관, 코어 템플릿 무오염). 저장은 "빈 코드=해제" 규칙을 따른다. - --- ## TL;DR (5초 요약) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 492b4b0d..16ec4181 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -9,6 +9,7 @@ use App\Extension\ModuleManager; use Database\Seeders\NotificationDefinitionSeeder; use Illuminate\Support\Facades\Log; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; +use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener; @@ -329,6 +330,7 @@ class Plugin extends AbstractPlugin * - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3) * - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅) * - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3) + * - LinkNotificationLogListener: 코어 알림 로그↔dispatch 연결(A-2 — 발송 이력 결과 주입 연결고리) * - ValidateBizppurioSettingsListener: 환경설정 검증 * * @return array @@ -339,6 +341,7 @@ class Plugin extends AbstractPlugin RegisterNotificationChannelsListener::class, SeedChannelTemplatesListener::class, GuestPhoneExtractListener::class, + LinkNotificationLogListener::class, ValidateBizppurioSettingsListener::class, ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json new file mode 100644 index 00000000..531ad00e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json @@ -0,0 +1,175 @@ +{ + "target_layout": "admin_notification_log_list", + "comment": "코어 알림 발송 이력 화면에 비즈뿌리오 발송 결과 컬럼을 얹는다(A-2). 코어 화면·코어 앱·코어 테이블은 전혀 건드리지 않는다 — 연결 표식은 우리 dispatch(bizppurio_dispatches.notification_log_id)에만 있고, 현재 페이지의 코어 로그 id 배열로 결과를 배치 조회(dispatchResults)해 row.id 로 매칭한다. 매칭 안 되는 행(메일·DB 등 비-비즈뿌리오)은 빈 셀. 컬럼은 코어 datagrid columns 배열 끝에 _append 로 1개만 추가(전체 컬럼 재정의 안 함).", + "data_sources": [ + { + "id": "dispatchResults", + "label_key": "$t:sirsoft-message_bizppurio.editor.data_source.dispatch_results", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "loading_strategy": "progressive", + "fallback": { + "data": { + "results": {} + } + } + } + ], + "injections": [ + { + "target_id": "notification_log_datagrid", + "position": "inject_props", + "comment": "코어 datagrid columns 끝에 비즈뿌리오 결과 컬럼 1개만 추가. columns 는 정적 배열이라 _append 적용 가능.", + "props": { + "expandChildren": { + "_append": [ + { + "comment": "행 토글(펼침) 안에도 비즈뿌리오 결과를 넣는다. 비즈뿌리오 발송 행(결과 매칭됨)일 때만 노출 — 메일·사이트내알림 등 매칭 안 되는 행에선 아무것도 안 보인다.", + "type": "basic", + "name": "Div", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}", + "props": { + "className": "p-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 space-y-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-comment-dots", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm font-medium text-gray-800 dark:text-gray-100" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.detail_title" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap items-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + }, + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}" + } + ] + } + ] + } + ] + }, + "columns": { + "_append": [ + { + "field": "bizppurio_result", + "header": "$t:sirsoft-message_bizppurio.dispatch_result.column_header", + "width": "200px", + "sortable": false, + "required": false, + "comment": "컬럼(헤더 포함) 자체를 탭별로 노출 제어. 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로(셀과 달리) query 접근 가능. 메일·사이트내알림(mail/database) 탭이면 hidden=true 로 컬럼 숨김. 전체·sms·lms·알림톡 탭에선 표시.", + "hidden": "{{['mail','database'].includes(query.channel ?? '')}}", + "cellChildren": [ + { + "comment": "비즈뿌리오 발송 행(row.channel 이 sms/lms/alimtalk)에서만 결과 표시. 메일·사이트내알림(mail/database) 행은 셀을 비운다. 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없으므로(코어 DataGrid renderCellChildren 계약) 탭이 아니라 row.channel 로 판별한다.", + "type": "basic", + "name": "Div", + "if": "{{['sms','lms','alimtalk'].includes(row.channel)}}", + "children": [ + { + "type": "basic", + "name": "Span", + "if": "{{!(dispatchResults?.data?.results ?? {})[row.id]}}", + "props": { + "className": "text-gray-300 dark:text-gray-600" + }, + "text": "-" + }, + { + "type": "basic", + "name": "Div", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}", + "props": { + "className": "flex flex-col gap-1 items-start" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + }, + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + }, + { + "type": "basic", + "name": "Span", + "if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance" + }, + { + "type": "basic", + "name": "Span", + "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}", + "props": { + "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}" + } + ] + } + ] + } + ] + } + ] + } + } + } + ], + "priority": 330 +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx new file mode 100644 index 00000000..d4d196ce --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx @@ -0,0 +1,190 @@ +/** + * 코어 알림 발송 이력 결과 컬럼 주입 렌더 테스트 (A-2) + * + * notification_log_result.json overlay 는 코어 "알림 발송 이력" 화면(admin_notification_log_list)의 + * DataGrid columns 에 결과 컬럼 1개를 _append 로 얹는다. 코어 화면·코어 앱·코어 테이블 무수정 — + * 현재 페이지의 코어 로그 id 배열로 결과(dispatchResults)를 배치 조회해 row.id 로 매칭한다. + * + * 이 테스트는 overlay 에서 결과 컬럼 정의를 그대로 추출해 실제 렌더한다(구조 검증이 아니라 렌더). + * row 컨텍스트는 iteration 으로 재현하고, dispatchResults 는 mockApi 로 채운다. 각 케이스에서 + * 올바른 배지 텍스트(상태 `사유 (코드)`·잔액부족·대체발송)가 DOM 에 뜨는지, 매칭 안 되는 행은 + * 빈 셀(-)인지 확인한다. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + createLayoutTest, + createMockComponentRegistryWithBasics, + screen, + type MockComponentRegistry, +} from '@core/template-engine/__tests__/utils/layoutTestUtils'; + +import overlay from '../../../extensions/notification_log_result.json'; + +/** overlay 에서 결과 컬럼(field: bizppurio_result)의 cellChildren 을 추출한다. */ +function getResultCellChildren(): any[] { + const inj = (overlay as any).injections.find( + (i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props', + ); + const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result'); + if (!column) throw new Error('결과 컬럼(bizppurio_result)을 찾지 못함'); + return column.cellChildren; +} + +/** 결과 컬럼 cellChildren 을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */ +function buildProbe() { + return { + version: '1.0.0', + layout_name: 'test/a2-dispatch-result', + data_sources: [ + { id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true }, + { id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true }, + ], + components: [ + { + type: 'basic', + name: 'Div', + iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' }, + props: { 'data-testid': 'result-cell' }, + children: getResultCellChildren(), + }, + ], + }; +} + +let registry: MockComponentRegistry; +beforeEach(() => { + registry = createMockComponentRegistryWithBasics(); +}); +afterEach(() => { + vi.clearAllMocks(); +}); + +/** 로그 행 목록. 셀 탭 가드가 row.channel 을 보므로 채널을 함께 준다(기본 sms). */ +const logs = (ids: number[], channel: string = 'sms') => ({ data: { data: ids.map((id) => ({ id, channel })) } }); + +describe('A-2 결과 컬럼 주입 — overlay 구조', () => { + it('코어 알림 발송 이력 datagrid 에 결과 컬럼을 _append 로 얹는다', () => { + expect((overlay as any).target_layout).toBe('admin_notification_log_list'); + const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid'); + expect(inj.position).toBe('inject_props'); + expect(inj.props.columns._append).toBeTruthy(); + }); + + it('결과는 파라미터 없이 GET 으로 최근 결과 맵을 받는다(타이밍 무관, kginicis 선례)', () => { + // 다른 data_source(notificationLogs)를 params 로 참조하면, params 가 notificationLogs 응답 + // 도착 전에 평가돼 빈 배열이 전송되는 타이밍 결함이 있다(브라우저 실측: 결과 컬럼 전부 빈 셀). + // 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭한다(로드 순서 무관). + const ds = (overlay as any).data_sources.find((d: any) => d.id === 'dispatchResults'); + expect(ds.method).toBe('GET'); + expect(ds.endpoint).toContain('/dispatch-results/recent'); + expect(ds.params).toBeUndefined(); + expect(ds.if).toBeUndefined(); + }); + + it('결과 컬럼 셀은 비즈뿌리오 발송 행(row.channel)에서만 표시한다(메일·사이트내알림 숨김)', () => { + const cell = getResultCellChildren(); + const guard = cell[0]; + // 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없다(코어 DataGrid renderCellChildren 계약). + // 따라서 탭(query.channel)이 아니라 row.channel 로 비즈뿌리오 발송 행을 판별한다. + expect(guard.if).toContain("['sms','lms','alimtalk'].includes(row.channel)"); + }); + + it('컬럼(헤더 포함)은 메일·사이트내알림 탭에서 hidden 으로 숨긴다', () => { + // 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로 query 접근 가능(셀과 다름). + // 메일·사이트내알림(mail/database) 탭이면 hidden=true → 헤더까지 숨긴다. + const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid'); + const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result'); + expect(column.hidden).toContain("['mail','database'].includes(query.channel"); + }); + + it('행 토글(expandChildren)에도 결과 블록을 append 한다(비즈뿌리오 발송 행만)', () => { + const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid'); + const appended = inj.props.expandChildren._append; + expect(appended).toBeTruthy(); + const block = appended[0]; + // 결과가 매칭된 행일 때만 토글에 노출. + expect(block.if).toContain('dispatchResults?.data?.results'); + expect(JSON.stringify(block)).toContain('dispatch_result.detail_title'); + }); + + it('배지는 다크모드에서 solid 색을 쓴다(색-불투명도 희석 금지)', () => { + const raw = JSON.stringify(overlay); + // /40 등 색-불투명도는 작은 배지에서 다크 배경과 섞여 묽어지므로 solid(dark:bg-*-700 등) 사용. + expect(raw).toContain('dark:bg-green-700'); + expect(raw).toContain('dark:bg-red-700'); + expect(raw).not.toContain('dark:bg-green-900/40'); + }); +}); + +describe('A-2 결과 컬럼 주입 — 렌더', () => { + it('성공 결과는 사유(코드) 라벨을 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([1]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 1: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } }, + }); + await utils.render(); + expect(screen.getByText('성공 (4100)')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('잔액부족 실패는 사유(코드) 라벨과 잔액부족 배지를 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([2]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '지갑 잔액 부족 (7436)', is_low_balance: true, fallback_status: null } } } }, + }); + await utils.render(); + // result_label 은 data 값(그대로 렌더). 잔액부족 배지는 is_low_balance=true 조건부 렌더 — + // $t: 라벨은 이 렌더 환경에서 원문 키로 남으므로 그 키 존재로 배지 렌더를 확인한다. + expect(screen.getByText('지갑 잔액 부족 (7436)')).toBeInTheDocument(); + expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('is_low_balance=false 이면 잔액부족 배지를 렌더하지 않는다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([2]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '음영 지역 (4400)', is_low_balance: false, fallback_status: null } } } }, + }); + await utils.render(); + expect(screen.getByText('음영 지역 (4400)')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('대체발송 결과가 있으면 대체발송 배지를 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([3]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 3: { status: 'success', status_label: '성공', result_label: '성공 (7000)', is_low_balance: false, fallback_status: '성공' } } } }, + }); + await utils.render(); + // 대체발송 배지는 fallback_status 존재 시 조건부 렌더 ($t: 라벨은 원문 키로 남음). + expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.fallback')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('비즈뿌리오 발송(sms) 행이지만 결과 미매칭이면 빈 셀(-)을 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([9], 'sms') }); + utils.mockApi('dispatchResults', { response: { data: { results: {} } } }); + await utils.render(); + expect(screen.getByText('-')).toBeInTheDocument(); + utils.cleanup(); + }); + + it('메일 채널 행은 셀 자체를 비운다(빈 셀 - 도 표시 안 함)', async () => { + // row.channel 이 mail 이면 셀 최상위 가드가 false → 셀 내용(빈 셀 - 포함) 전체 미렌더. + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([10], 'mail') }); + utils.mockApi('dispatchResults', { response: { data: { results: {} } } }); + await utils.render(); + expect(screen.queryByText('-')).not.toBeInTheDocument(); + utils.cleanup(); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts new file mode 100644 index 00000000..5eeecb61 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts @@ -0,0 +1,25 @@ +/** + * 비즈뿌리오 플러그인 레이아웃 렌더 테스트 환경 설정. + * + * jest-dom matcher(toBeInTheDocument 등)를 로드하고, jsdom 전역 mock 을 준비한다. + * 코어 레이아웃 렌더 테스트(createLayoutTest)를 사용하는 테스트가 소비한다. + */ + +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; + +if (typeof window !== 'undefined') { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index a1d65e73..414c1c6e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -84,6 +84,18 @@ "setup_action": "Set up", "test_mode": "Test mode — messages are not actually sent." }, + "dispatch_result": { + "column_header": "SMS/Alimtalk Result", + "low_balance": "Low balance", + "fallback": "SMS fallback {status}", + "detail_title": "SMS/Alimtalk delivery result", + "channel_label": "Channel: {channel}" + }, + "editor": { + "data_source": { + "dispatch_results": "Bizppurio dispatch results" + } + }, "templates": { "title": "Alimtalk Templates", "description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 42137dc1..4ddcb2d5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -84,6 +84,18 @@ "setup_action": "설정하기", "test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다." }, + "dispatch_result": { + "column_header": "문자·알림톡 결과", + "low_balance": "잔액 부족", + "fallback": "SMS 대체발송 {status}", + "detail_title": "문자·알림톡 발송 결과", + "channel_label": "발송 채널: {channel}" + }, + "editor": { + "data_source": { + "dispatch_results": "비즈뿌리오 발송 결과" + } + }, "templates": { "title": "알림톡 템플릿", "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php new file mode 100644 index 00000000..c3b4f21e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php @@ -0,0 +1,61 @@ + $this->service->resultsForLogIds($request->logIds()), + ]); + } + + /** + * 최근 연결된 비즈뿌리오 결과 맵을 반환합니다 (코어 이력 화면 결과 컬럼용, 타이밍 무관). + * + * 파라미터 없이 GET 으로 최근 결과 맵을 받아 화면이 row.id 로 매칭한다(kginicis test-map 선례). + * 목록 data_source(notificationLogs) 로드 순서에 의존하지 않아, 결과 컬럼이 비는 문제를 원천 차단. + * + * @return JsonResponse data.results 에 notification_log_id → 결과 맵 + */ + public function recent(): JsonResponse + { + return ResponseHelper::success('messages.success', [ + 'results' => $this->service->recentResults(), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php new file mode 100644 index 00000000..cfaba4a9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php @@ -0,0 +1,52 @@ + + */ + public function rules(): array + { + return [ + // 빈 배열 허용: 코어 알림 발송 이력이 0건(또는 로드 전)이면 화면이 빈 배열을 보낸다. + // 이때 422 로 막으면 결과 컬럼 조회 자체가 에러가 되므로, present(키 존재)만 요구하고 + // 빈 배열은 통과시켜 빈 결과 맵을 돌려준다. + 'notification_log_ids' => ['present', 'array', 'max:100'], + 'notification_log_ids.*' => ['integer', 'min:1'], + ]; + } + + /** + * 검증된 코어 알림 로그 id 목록을 정수 배열로 반환합니다. + * + * @return array 로그 id 목록 + */ + public function logIds(): array + { + return array_map('intval', $this->validated('notification_log_ids', [])); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php index fe3e6b36..60337d20 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php @@ -25,6 +25,8 @@ class StoreAlimtalkTemplateRequest extends FormRequest { /** * 권한 검사는 라우트 미들웨어가 담당 — 항상 통과. + * + * @return bool */ public function authorize(): bool { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php index 4a7f9e9d..3ca1a11c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php @@ -16,6 +16,8 @@ class StoreNotificationBindingRequest extends FormRequest { /** * 권한은 라우트 미들웨어(messaging.manage)에서 처리한다. + * + * @return bool */ public function authorize(): bool { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php index 4f5b031e..e663bb5e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php @@ -18,6 +18,8 @@ class UploadTemplateImageRequest extends FormRequest { /** * 권한 검사는 라우트 미들웨어가 담당 — 항상 통과. + * + * @return bool */ public function authorize(): bool { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php new file mode 100644 index 00000000..0f0f966b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php @@ -0,0 +1,90 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'core.notification_log.after_log_sent' => ['method' => 'linkLog', 'priority' => 15], + 'core.notification_log.after_log_failed' => ['method' => 'linkLog', 'priority' => 15], + ]; + } + + /** + * 기본 핸들러 (미사용 — linkLog 로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 방금 생성된 코어 알림 로그를 이번 발송 사이클의 dispatch 에 연결합니다. + * + * 이 사이클에서 우리 드라이버가 남긴 refkey 가 있으면 그 dispatch 에 로그 id 를 기록한다. + * refkey 가 없으면(비-비즈뿌리오 로그) 연결하지 않는다. + * + * @param NotificationLog $log 코어가 방금 생성한 로그(id 포함) + */ + public function linkLog(NotificationLog $log): void + { + $refkey = $this->linkContext->consume(); + + if ($refkey === null) { + return; + } + + try { + $this->dispatches->linkNotificationLog($refkey, (int) $log->id); + } catch (\Throwable $e) { + Log::warning('비즈뿌리오: 코어 알림 로그 연결 실패', [ + 'refkey' => $refkey, + 'notification_log_id' => $log->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php index 2a8c5903..fbb57f22 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Models; use App\Models\User; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -28,16 +29,17 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; * @property int|null $to_user_id * @property string $content * @property string|null $notification_type + * @property int|null $notification_log_id * @property string $status * @property string|null $result_code * @property string|null $result_message * @property string|null $fallback_status * @property string $source - * @property \Carbon\Carbon|null $sent_at - * @property \Carbon\Carbon|null $reported_at + * @property Carbon|null $sent_at + * @property Carbon|null $reported_at * @property array|null $raw_payload - * @property \Carbon\Carbon|null $created_at - * @property \Carbon\Carbon|null $updated_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property-read User|null $user */ class BizppurioDispatch extends Model @@ -64,6 +66,7 @@ class BizppurioDispatch extends Model 'to_user_id', 'content', 'notification_type', + 'notification_log_id', 'status', 'result_code', 'result_message', @@ -86,6 +89,7 @@ class BizppurioDispatch extends Model 'status' => DispatchStatus::class, 'source' => DispatchSource::class, 'to_user_id' => 'integer', + 'notification_log_id' => 'integer', 'sent_at' => 'datetime', 'reported_at' => 'datetime', 'raw_payload' => 'array', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 774cedf1..d75e915e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -12,6 +12,7 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRep use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; +use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService; @@ -51,6 +52,20 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider WebhookReportService::class, ]; + /** + * 서비스 컨테이너 바인딩을 등록합니다. + * + * DispatchLinkContext 는 한 발송 사이클(HTTP 요청/큐 잡) 안에서 refkey↔코어 로그 연결을 + * 잇기 위해 상태를 공유해야 하므로 scoped(요청 단위 싱글턴)로 바인딩한다. 채널 드라이버와 + * LinkNotificationLogListener 가 같은 인스턴스를 주입받아 refkey 를 주고받는다(A-2). + */ + public function register(): void + { + parent::register(); + + $this->app->scoped(DispatchLinkContext::class); + } + /** * 플러그인 루트 lang 디렉토리를 로드합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php index 8f52d1af..35e20d58 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Repositories; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; @@ -87,4 +88,59 @@ class BizppurioDispatchRepository implements BizppurioDispatchRepositoryInterfac return $query->orderByDesc('created_at')->paginate($perPage); } + + /** + * refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리). + * + * @param string $refkey 발송 사이클에서 부여한 refkey + * @param int $notificationLogId 코어 notification_logs.id + * @return bool 연결 성공 여부(대상 없으면 false) + */ + public function linkNotificationLog(string $refkey, int $notificationLogId): bool + { + $dispatch = BizppurioDispatch::query()->byRefkey($refkey)->first(); + + if ($dispatch === null) { + return false; + } + + $dispatch->notification_log_id = $notificationLogId; + $dispatch->save(); + + return true; + } + + /** + * 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시). + * + * @param array $notificationLogIds 코어 로그 id 목록 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection + { + if ($notificationLogIds === []) { + return collect(); + } + + return BizppurioDispatch::query() + ->whereIn('notification_log_id', $notificationLogIds) + ->get() + ->keyBy('notification_log_id'); + } + + /** + * 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관). + * + * @param int $limit 최근 dispatch 조회 상한 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function recentLinkedKeyed(int $limit = 1000): Collection + { + return BizppurioDispatch::query() + ->whereNotNull('notification_log_id') + ->orderByDesc('id') + ->limit($limit) + ->get() + ->keyBy('notification_log_id'); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php index c4ed0052..6e6935da 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts; use Illuminate\Contracts\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; /** @@ -48,4 +49,40 @@ interface BizppurioDispatchRepositoryInterface * @return LengthAwarePaginator */ public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator; + + /** + * refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리). + * + * LinkNotificationLogListener 가 발송 사이클 직후 코어 로그 id 를 이 dispatch 에 기록할 때 + * 호출한다. refkey 가 없거나(비-비즈뿌리오 로그) 이미 연결됐으면 아무 것도 하지 않는다. + * + * @param string $refkey 발송 사이클에서 부여한 refkey + * @param int $notificationLogId 코어 notification_logs.id + * @return bool 연결 성공 여부(대상 없으면 false) + */ + public function linkNotificationLog(string $refkey, int $notificationLogId): bool; + + /** + * 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시). + * + * 코어 알림 발송 이력 화면이 현재 페이지의 log id 배열로 비즈뿌리오 결과를 한 번에 조회한다 + * (N+1 회피). 매칭되지 않는 log id(메일·DB 등 비-비즈뿌리오)는 맵에서 빠진다. + * + * @param array $notificationLogIds 코어 로그 id 목록 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection; + + /** + * 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관). + * + * 화면이 파라미터 없이 GET 으로 최근 결과 맵을 받아 row.id 로 매칭한다(kginicis test-map 선례). + * params 로 로그 id 를 넘기지 않으므로 목록 data_source 로드 순서에 의존하지 않는다. 이력 화면은 + * 페이지네이션(20건)이라 최근 N건 맵으로 현재 화면을 덮는다. notification_log_id 가 없는 + * dispatch(아직 연결 전)는 제외한다. + * + * @param int $limit 최근 dispatch 조회 상한 + * @return Collection notification_log_id 를 키로 하는 dispatch 맵 + */ + public function recentLinkedKeyed(int $limit = 1000): Collection; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php index 4b8f5bf9..94847966 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -51,12 +51,14 @@ class AlimtalkChannelDriver * @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회 * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화 + * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) */ public function __construct( private readonly NotificationTemplateService $templateService, private readonly BizppurioNotificationBindingRepositoryInterface $bindings, private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, + private readonly DispatchLinkContext $linkContext, ) {} /** @@ -132,6 +134,10 @@ class AlimtalkChannelDriver 'sent_at' => now(), ]); + // A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록 + // refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다. + $this->linkContext->remember($refkey); + SendMessageJob::dispatch($payload, $refkey); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php new file mode 100644 index 00000000..42fe4fc5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php @@ -0,0 +1,55 @@ +pendingRefkey = $refkey; + } + + /** + * 링크 리스너가 최근 refkey 를 꺼냅니다 (꺼내면 비웁니다). + * + * 비-비즈뿌리오 채널(mail/database 등)의 로그가 발화한 경우엔 이 사이클에서 remember 된 + * refkey 가 없으므로 null 을 반환한다(그 로그는 연결 대상 아님). + * + * @return string|null 최근 refkey 또는 null + */ + public function consume(): ?string + { + $refkey = $this->pendingRefkey; + $this->pendingRefkey = null; + + return $refkey; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php new file mode 100644 index 00000000..d21b49cd --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php @@ -0,0 +1,97 @@ + $notificationLogIds 코어 로그 id 목록(현재 페이지) + * @return array> notification_log_id → 결과 표시 데이터 + */ + public function resultsForLogIds(array $notificationLogIds): array + { + $dispatchMap = $this->dispatches->findByNotificationLogIdsKeyed($notificationLogIds); + + return $this->buildResultMap($dispatchMap); + } + + /** + * 최근 연결된 dispatch 결과를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관). + * + * 화면이 파라미터 없이 조회해 row.id 로 매칭한다(kginicis test-map 선례). 목록 data_source + * 로드 순서에 의존하지 않아 결과 컬럼이 빈 배열로 비는 문제를 원천 차단한다. + * + * @param int $limit 최근 dispatch 조회 상한 + * @return array> notification_log_id → 결과 표시 데이터 + */ + public function recentResults(int $limit = 1000): array + { + return $this->buildResultMap($this->dispatches->recentLinkedKeyed($limit)); + } + + /** + * dispatch 맵을 log id 키의 결과 표시 배열 맵으로 변환합니다. + * + * @param \Illuminate\Support\Collection $dispatchMap log id 키 dispatch 맵 + * @return array> notification_log_id → 결과 표시 데이터 + */ + private function buildResultMap(\Illuminate\Support\Collection $dispatchMap): array + { + $results = []; + + foreach ($dispatchMap as $logId => $dispatch) { + $results[(int) $logId] = $this->buildResult($dispatch); + } + + return $results; + } + + /** + * dispatch 1건을 화면 표시용 결과 배열로 변환합니다. + * + * @param BizppurioDispatch $dispatch 연결된 발송 이력 + * @return array 상태·결과 라벨·잔액부족·대체발송 표시 데이터 + */ + private function buildResult(BizppurioDispatch $dispatch): array + { + $code = $dispatch->result_code; + $hasCode = $code !== null && $code !== ''; + + return [ + 'status' => $dispatch->status?->value, + 'status_label' => $dispatch->status?->label(), + 'result_code' => $code, + // `사유 (코드)` 표시 라벨. 코드가 없으면(리포트 미수신) null → 화면은 상태 라벨만 표시. + 'result_label' => $hasCode ? $this->resultCodes->label($code) : null, + 'is_low_balance' => $hasCode && $this->resultCodes->isBalanceLow($code), + 'fallback_status' => $dispatch->fallback_status, + 'channel' => $dispatch->channel?->value, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index d9e3856e..f9a953dc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -45,12 +45,14 @@ class SmsChannelDriver * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별 * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) + * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) */ public function __construct( private readonly NotificationTemplateService $templateService, private readonly SmsTypeResolver $typeResolver, private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, + private readonly DispatchLinkContext $linkContext, ) {} /** @@ -118,6 +120,10 @@ class SmsChannelDriver 'sent_at' => now(), ]); + // A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록 + // refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다. + $this->linkContext->remember($refkey); + SendMessageJob::dispatch($payload, $refkey); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 30d29971..e92010a0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Middleware\EnforceIdentityPolicy; use App\Http\Middleware\RefreshTokenExpiration; use Illuminate\Support\Facades\Route; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController; +use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController; use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController; use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; @@ -106,4 +107,24 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g Route::post('/', [NotificationBindingController::class, 'store'])->name('store'); }); }); + + /* + |---------------------------------------------------------------------- + | 코어 알림 발송 이력 결과 조회 (A-2) — 코어 이력 화면 overlay 가 소비 + |---------------------------------------------------------------------- + | + | 코어 "알림 발송 이력" 화면에 plugin overlay 로 얹은 결과 컬럼이, 현재 페이지의 코어 알림 + | 로그 id 배열을 이 API 로 넘겨 비즈뿌리오 결과(상태·사유·잔액부족·대체발송)를 한 번에 조회 + | 한다(N+1 회피). 코어 화면·코어 테이블 무수정 — 연결 표식은 우리 dispatch 쪽에만 둔다. + | + | 조회 = messaging.view. + */ + Route::prefix('dispatch-results')->name('dispatch-results.')->group(function () { + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + // 화면 결과 컬럼용: 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭(타이밍 무관, 기본 경로). + Route::get('/recent', [DispatchResultController::class, 'recent'])->name('recent'); + // 명시 로그 id 배열 조회(직접 조회용). 화면은 recent 를 쓴다. + Route::post('/lookup', [DispatchResultController::class, 'lookup'])->name('lookup'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php new file mode 100644 index 00000000..9cf9527b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php @@ -0,0 +1,228 @@ + $permissionIds 부여할 권한 식별자 + */ + private function adminWith(array $permissionIds): User + { + $user = User::factory()->create(); + + $adminRole = Role::firstOrCreate( + ['identifier' => 'admin'], + ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin'] + ); + + $permIds = []; + foreach ($permissionIds as $identifier) { + $permission = Permission::firstOrCreate( + ['identifier' => $identifier], + ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin'] + ); + $permIds[] = $permission->id; + } + + $testRole = Role::create([ + 'identifier' => 'bizppurio_result_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'type' => 'admin', + ]); + $testRole->permissions()->sync($permIds); + + $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]); + + return $user->fresh(); + } + + /** + * 사용자 토큰 헤더를 만듭니다. + */ + private function authHeaders(User $user): array + { + return [ + 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken, + 'Accept' => 'application/json', + ]; + } + + /** + * 코어 알림 로그 1건 + 연결된 dispatch 1건을 만들고 로그 id 를 반환합니다. + * + * @param array $dispatchAttrs dispatch 오버라이드 + */ + private function seedLinkedLog(array $dispatchAttrs = []): int + { + $log = NotificationLog::create([ + 'channel' => 'sms', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'recipient_name' => '홍길동', + 'status' => 'sent', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + BizppurioDispatch::create(array_merge([ + 'refkey' => 'rk_'.uniqid(), + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => '본문', + 'notification_type' => 'welcome', + 'notification_log_id' => $log->id, + 'status' => 'success', + 'result_code' => '4100', + 'source' => 'auto', + 'sent_at' => now(), + ], $dispatchAttrs)); + + return (int) $log->id; + } + + private const RECENT_URL = '/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent'; + + public function test_인증_없이_조회는_401이다(): void + { + $this->postJson(self::URL, ['notification_log_ids' => [1]])->assertStatus(401); + } + + public function test_recent_는_view_권한_없으면_403이다(): void + { + $user = $this->adminWith([]); + + $this->getJson(self::RECENT_URL, $this->authHeaders($user))->assertStatus(403); + } + + public function test_recent_는_파라미터_없이_최근_결과맵을_반환한다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(['result_code' => '4100']); + + $result = $this->getJson(self::RECENT_URL, $this->authHeaders($user)) + ->assertOk() + ->json("data.results.{$logId}"); + + $this->assertSame('success', $result['status']); + $this->assertSame('4100', $result['result_code']); + } + + public function test_view_권한_없으면_403이다(): void + { + $user = $this->adminWith([]); // 권한 없음 + $logId = $this->seedLinkedLog(); + + $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertStatus(403); + } + + public function test_로그_id_키가_아예_없으면_422다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $this->postJson(self::URL, [], $this->authHeaders($user)) + ->assertStatus(422) + ->assertJsonValidationErrors(['notification_log_ids']); + } + + public function test_빈_배열은_200과_빈_결과맵을_반환한다(): void + { + // 코어 알림 발송 이력이 0건(또는 아직 미로드)일 때 화면은 빈 배열을 보낸다. + // 이 경우 422 가 아니라 200 + 빈 결과 맵이어야 화면이 깨지지 않는다(회귀: 브라우저 실측). + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->postJson(self::URL, ['notification_log_ids' => []], $this->authHeaders($user)) + ->assertOk(); + + $this->assertSame([], $response->json('data.results')); + } + + public function test_성공_결과를_로그id_키_맵으로_반환한다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(); + + $response = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertOk(); + + $result = $response->json("data.results.{$logId}"); + $this->assertSame('success', $result['status']); + $this->assertSame('4100', $result['result_code']); + $this->assertFalse($result['is_low_balance']); + } + + public function test_실패_음영지역은_사유_코드_라벨을_반환한다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(['status' => 'failed', 'result_code' => '4400']); + + $result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertOk() + ->json("data.results.{$logId}"); + + // result_label 은 `사유 (코드)` 형식 — result_codes lang 에 4400 정의가 있으면 사유가 붙는다. + $this->assertSame('failed', $result['status']); + $this->assertStringContainsString('4400', (string) $result['result_label']); + } + + public function test_잔액부족_코드는_is_low_balance_true다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $logId = $this->seedLinkedLog(['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']); + + $result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user)) + ->assertOk() + ->json("data.results.{$logId}"); + + $this->assertTrue($result['is_low_balance']); + } + + public function test_매칭되지_않는_로그id는_결과맵에서_빠진다(): void + { + $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + $linkedId = $this->seedLinkedLog(); + + // 연결되지 않은 코어 로그(메일 등) — dispatch 없음 + $mailLog = NotificationLog::create([ + 'channel' => 'mail', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => 'a@b.com', + 'status' => 'sent', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $results = $this->postJson( + self::URL, + ['notification_log_ids' => [$linkedId, $mailLog->id]], + $this->authHeaders($user) + )->assertOk()->json('data.results'); + + $this->assertArrayHasKey((string) $linkedId, $results); + $this->assertArrayNotHasKey((string) $mailLog->id, $results); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php new file mode 100644 index 00000000..626e5752 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php @@ -0,0 +1,159 @@ + $config) { + HookManager::addAction( + $hook, + fn (...$args) => app(LinkNotificationLogListener::class)->{$config['method']}(...$args), + $config['priority'] ?? 10, + ); + } + } + + /** + * 발송 사이클을 흉내내 pending dispatch 를 만들고 refkey 를 컨텍스트에 남깁니다. + * + * 채널 드라이버 send() 의 dispatch 생성 + linkContext->remember() 와 동일한 상태를 만든다. + * + * @param string $channel 발송 채널 + * @return string 부여한 refkey + */ + private function beginDispatchCycle(string $channel = 'sms'): string + { + $refkey = 'rk_'.uniqid(); + + BizppurioDispatch::create([ + 'refkey' => $refkey, + 'channel' => $channel, + 'to_number' => '01011112222', + 'content' => '본문', + 'notification_type' => 'welcome', + 'status' => 'pending', + 'source' => 'auto', + 'sent_at' => now(), + ]); + + // 발송 사이클과 동일한 싱글턴 인스턴스에 refkey 를 남긴다. + app(DispatchLinkContext::class)->remember($refkey); + + return $refkey; + } + + public function test_발송_성공_로그가_dispatch에_연결된다(): void + { + $refkey = $this->beginDispatchCycle(); + + // 코어 로그 서비스로 실제 로그 생성 → after_log_sent 훅 발화 → 우리 리스너가 연결 + $log = app(NotificationLogService::class)->logSent([ + 'channel' => 'sms', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'recipient_name' => '홍길동', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first(); + $this->assertNotNull($dispatch); + $this->assertSame((int) $log->id, (int) $dispatch->notification_log_id); + } + + public function test_발송_실패_로그도_dispatch에_연결된다(): void + { + $refkey = $this->beginDispatchCycle('alimtalk'); + + $log = app(NotificationLogService::class)->logFailed([ + 'channel' => 'alimtalk', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'error_message' => 'boom', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first(); + $this->assertSame((int) $log->id, (int) $dispatch->notification_log_id); + } + + public function test_비_비즈뿌리오_로그는_연결되지_않는다(): void + { + // remember() 를 부르지 않은 상태(mail/database 등 비-비즈뿌리오 발송 사이클) + $log = app(NotificationLogService::class)->logSent([ + 'channel' => 'mail', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => 'a@b.com', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + // 어떤 dispatch 도 이 로그에 연결되지 않아야 한다. + $linked = BizppurioDispatch::query()->where('notification_log_id', $log->id)->exists(); + $this->assertFalse($linked); + } + + public function test_한_refkey는_한_로그에만_연결된다_consume_후_비워짐(): void + { + $refkey = $this->beginDispatchCycle(); + + // 첫 로그 → 연결됨 + $first = app(NotificationLogService::class)->logSent([ + 'channel' => 'sms', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + // 같은 사이클에서 refkey 를 다시 남기지 않은 채 둘째 로그 발생(다른 채널) → 연결 안 됨 + $second = app(NotificationLogService::class)->logSent([ + 'channel' => 'database', + 'notification_type' => 'welcome', + 'extension_type' => 'core', + 'extension_identifier' => 'core', + 'recipient_identifier' => '01011112222', + 'source' => 'notification', + 'sent_at' => now(), + ]); + + $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first(); + $this->assertSame((int) $first->id, (int) $dispatch->notification_log_id); + $this->assertFalse( + BizppurioDispatch::query()->where('notification_log_id', $second->id)->exists() + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php index a9c8ea35..00ee566a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php @@ -72,4 +72,64 @@ class BizppurioDispatchRepositoryTest extends PluginTestCase $byNumber = $this->repo->paginate(['keyword' => '9999']); $this->assertSame(1, $byNumber->total()); } + + public function test_link_notification_log_by_refkey(): void + { + $this->makeDispatch(['refkey' => 'to_link']); + + $linked = $this->repo->linkNotificationLog('to_link', 777); + $this->assertTrue($linked); + $this->assertSame(777, (int) $this->repo->findByRefkey('to_link')->notification_log_id); + } + + public function test_link_notification_log_missing_refkey_returns_false(): void + { + $this->assertFalse($this->repo->linkNotificationLog('nope', 1)); + } + + public function test_find_by_notification_log_ids_keyed(): void + { + $this->makeDispatch(['refkey' => 'a', 'notification_log_id' => 10]); + $this->makeDispatch(['refkey' => 'b', 'notification_log_id' => 20]); + $this->makeDispatch(['refkey' => 'c', 'notification_log_id' => null]); // 미연결 + + $map = $this->repo->findByNotificationLogIdsKeyed([10, 20, 30]); + + $this->assertCount(2, $map); + $this->assertSame('a', $map[10]->refkey); + $this->assertSame('b', $map[20]->refkey); + $this->assertFalse($map->has(30)); + } + + public function test_find_by_notification_log_ids_empty_returns_empty(): void + { + $this->assertTrue($this->repo->findByNotificationLogIdsKeyed([])->isEmpty()); + } + + public function test_recent_linked_keyed_excludes_unlinked_and_keys_by_log_id(): void + { + $this->makeDispatch(['refkey' => 'x', 'notification_log_id' => 11]); + $this->makeDispatch(['refkey' => 'y', 'notification_log_id' => 22]); + $this->makeDispatch(['refkey' => 'z', 'notification_log_id' => null]); // 미연결 → 제외 + + $map = $this->repo->recentLinkedKeyed(); + + $this->assertCount(2, $map); + $this->assertSame('x', $map[11]->refkey); + $this->assertSame('y', $map[22]->refkey); + } + + public function test_recent_linked_keyed_respects_limit(): void + { + foreach (range(1, 5) as $i) { + $this->makeDispatch(['refkey' => 'r'.$i, 'notification_log_id' => $i]); + } + + // 최신(id 큰) 것부터 limit 만큼 — 최근 2건만 + $map = $this->repo->recentLinkedKeyed(2); + + $this->assertCount(2, $map); + $this->assertTrue($map->has(5)); + $this->assertTrue($map->has(4)); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php index e45adb08..153934a8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -16,6 +16,7 @@ use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver; +use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext; use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -90,6 +91,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->fakeBindings($binding), $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, + new DispatchLinkContext, ); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php new file mode 100644 index 00000000..d9f3d269 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php @@ -0,0 +1,113 @@ +service = new DispatchResultService( + new BizppurioDispatchRepository, + new ResultCodeResolver, + ); + } + + /** + * 코어 로그 id 에 연결된 dispatch 1건을 만듭니다. + * + * @param array $overrides dispatch 오버라이드 + */ + private function linkedDispatch(int $logId, array $overrides = []): BizppurioDispatch + { + return BizppurioDispatch::create(array_merge([ + 'refkey' => 'r'.uniqid(), + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'notification_type' => 'welcome', + 'notification_log_id' => $logId, + 'status' => 'success', + 'result_code' => '4100', + 'source' => 'auto', + 'sent_at' => now(), + ], $overrides)); + } + + public function test_성공_결과를_로그id_키_맵으로_반환한다(): void + { + $this->linkedDispatch(10); + + $results = $this->service->resultsForLogIds([10]); + + $this->assertArrayHasKey(10, $results); + $this->assertSame('success', $results[10]['status']); + $this->assertSame('4100', $results[10]['result_code']); + $this->assertFalse($results[10]['is_low_balance']); + // 민감정보(전화번호)는 결과에 포함하지 않는다. + $this->assertArrayNotHasKey('to_number', $results[10]); + } + + public function test_잔액부족_코드는_is_low_balance_true다(): void + { + $this->linkedDispatch(20, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']); + + $results = $this->service->resultsForLogIds([20]); + + $this->assertTrue($results[20]['is_low_balance']); + } + + public function test_대체발송_상태가_결과에_포함된다(): void + { + $this->linkedDispatch(30, ['fallback_status' => '성공']); + + $results = $this->service->resultsForLogIds([30]); + + $this->assertSame('성공', $results[30]['fallback_status']); + } + + public function test_result_code_없으면_result_label_null이다(): void + { + $this->linkedDispatch(40, ['status' => 'sent', 'result_code' => null]); + + $results = $this->service->resultsForLogIds([40]); + + $this->assertNull($results[40]['result_label']); + $this->assertNotNull($results[40]['status_label']); // 상태 라벨은 있음(발송중) + } + + public function test_매칭되지_않는_로그id는_맵에서_빠진다(): void + { + $this->linkedDispatch(50); + + $results = $this->service->resultsForLogIds([50, 999]); + + $this->assertArrayHasKey(50, $results); + $this->assertArrayNotHasKey(999, $results); + } + + public function test_recent_results_는_연결된_최근_결과를_로그id_키_맵으로_반환한다(): void + { + $this->linkedDispatch(60, ['result_code' => '4100']); + $this->linkedDispatch(61, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']); + + $results = $this->service->recentResults(); + + $this->assertSame('success', $results[60]['status']); + $this->assertTrue($results[61]['is_low_balance']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index 0028ede4..bc222347 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -10,9 +10,10 @@ use App\Services\NotificationTemplateService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; -use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; +use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext; use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver; @@ -59,6 +60,7 @@ class SmsChannelDriverTest extends PluginTestCase new SmsTypeResolver, $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, + new DispatchLinkContext, ); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml new file mode 100644 index 00000000..72b15fe0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml @@ -0,0 +1,91 @@ +# audit:allow test-scenario-coverage reason: | +# A-2 는 코어 알림 발송 이력 화면에 비즈뿌리오 결과(도달·실패사유·잔액부족·대체발송)를 얹는 +# 작업이다(코어 앱·코어 테이블 무수정). axes cross product 의 런타임 효과는 계층별로 전수 가드된다: +# - 연결고리(코어 로그↔dispatch): NotificationLogLinkTest — 실제 훅 체인(after_log_sent/ +# after_log_failed)으로 dispatch.notification_log_id 연결, 비-비즈뿌리오 로그 미연결, refkey +# consume 후 비워짐(오매칭 방지)까지 관찰. +# - 링크·배치조회 Repository: BizppurioDispatchRepositoryTest — link/미존재/keyed/빈배열. +# - 결과 표시 파생(상태·사유(코드)·잔액부족·대체발송·미매칭 제외): DispatchResultServiceTest. +# - 결과 조회 API(권한·검증·성공/실패/잔액부족/미매칭): DispatchResultEndpointTest. +# - overlay 결과 컬럼 실제 렌더(성공/잔액부족/대체발송/빈셀 조건부 렌더): notification_log_result.test.tsx. +# e2e_* 축(브라우저 왕복: 코어 이력 화면 진입→결과 컬럼 확인→회원 링크/배지)은 실제 발송·webhook +# 왕복 결과가 필요해, 계획서 §9 방침대로 PO 직접 수행하는 실연동 검증 단계에서 Playwright spec 을 +# 그때 @scenario/@effects 로 이 매니페스트에 매핑한다. Vitest 렌더 테스트가 프론트 overlay 컬럼 +# 주입·조건부 렌더를 가드하므로 이번 단계는 spec 미도입 +# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 렌더 커버로 충족). + +feature: 비즈뿌리오 발송 결과를 코어 알림 발송 이력 화면에 주입 (A-2) + +description: | + 비즈뿌리오로 보낸 문자·알림톡의 실제 결과(성공/실패·사유·잔액부족·대체발송)를 코어 "알림 발송 + 이력" 화면의 각 행에 붙여 한 화면에서 본다. 독립 페이지·메뉴 없음. 코어 앱·코어 테이블 무수정 — + 연결 표식은 비즈뿌리오 쪽(bizppurio_dispatches.notification_log_id)에만 두고, layout_extensions + overlay 로 결과 컬럼을 코어 이력 datagrid 에 _append 한다. + + 핵심 동작: + - 연결고리: 채널 드라이버가 발송 사이클에서 dispatch(refkey) 생성 후 DispatchLinkContext 에 + refkey 를 남기고, 직후 발화하는 코어 after_log_sent/after_log_failed 훅에서 + LinkNotificationLogListener 가 그 refkey 로 dispatch 에 코어 로그 id 를 기록한다(직접 표식, + 복합키 근사 매칭 아님 — 동일 시각 중복 발송 오매칭 방지). + - 결과 조회 API: 코어 이력 화면이 현재 페이지 로그 id 배열을 POST 로 넘기면(N+1 회피) 결과 맵 + 반환. 상태·`사유 (코드)`(ResultCodeResolver 재사용)·잔액부족 여부·대체발송 상태. 전화번호 등 + 민감정보 미포함(결과 컬럼엔 노출 안 함). + - overlay: 코어 datagrid columns 에 결과 컬럼 1개 _append. row.id 로 결과 맵 매칭. 매칭 안 되는 + 행(메일·DB 등 비-비즈뿌리오)은 빈 셀(-). + +axes: + channel: [sms, lms, alimtalk] + result_category: [success, failure, balance_low] + fallback: [none, present] + recipient_kind: [member, guest] + link_outcome: [linked, unmatched] + e2e_view_action: [open_history, view_result_cell, open_member] + +exclusions: + - { link_outcome: unmatched, result_category: success, reason: "미매칭(비-비즈뿌리오) 행은 결과 자체가 없어 result_category 조합 무의미 — 빈 셀 한 케이스로 충분" } + - { link_outcome: unmatched, fallback: present, reason: "미매칭 행은 대체발송 결과도 없음" } + - { recipient_kind: guest, e2e_view_action: open_member, reason: "비회원은 회원 상세 링크 없음(일반 텍스트) — 회원 축과 무관" } + +effects: + # 연결고리 (백엔드 — 실제 훅 체인) + - sent_log_links_dispatch_via_refkey_in_same_cycle + - failed_log_links_dispatch_via_refkey_in_same_cycle + - non_bizppurio_log_leaves_dispatch_unlinked + - refkey_consumed_once_prevents_cross_cycle_mismatch + # 링크·배치조회 Repository (백엔드) + - link_notification_log_by_refkey_sets_log_id + - link_missing_refkey_returns_false + - find_by_log_ids_returns_keyed_map_excluding_unmatched + # 결과 표시 파생 (백엔드) + - success_result_derives_status_and_reason_code_label + - balance_low_result_flags_is_low_balance_true + - fallback_status_surfaced_in_result + - missing_result_code_yields_null_result_label_with_status_label + - unmatched_log_id_excluded_from_result_map + - result_map_excludes_recipient_phone_masking_not_needed + # 결과 조회 API (백엔드) + - lookup_requires_messaging_view_permission + - lookup_requires_log_ids_array + - lookup_returns_results_keyed_by_log_id + # overlay 결과 컬럼 렌더 (프론트) + - overlay_appends_result_column_to_core_history_datagrid + - result_column_batch_fetches_by_current_page_log_ids + - success_cell_renders_reason_code_label + - balance_low_cell_renders_low_balance_badge + - non_low_balance_cell_omits_low_balance_badge + - fallback_cell_renders_fallback_badge + - unmatched_row_renders_empty_cell + # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상) + - e2e_open_history_shows_result_column + - e2e_result_cell_shows_status_reason_and_badges + - e2e_member_recipient_links_to_user_detail + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx + +audit: + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts index ffd85611..ae686dc2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts @@ -6,11 +6,12 @@ export default defineConfig({ environment: 'jsdom', globals: true, include: ['resources/js/__tests__/**/*.test.{ts,tsx}'], - setupFiles: [], + setupFiles: ['./resources/js/__tests__/setup.ts'], }, resolve: { alias: { '@': resolve(__dirname, 'resources/js'), + '@core': resolve(__dirname, '../../../resources/js/core'), }, }, }); From 5e9d046812b58431dbbb5cbdfadde45fd6b34976 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 21 Jul 2026 16:46:30 +0900 Subject: [PATCH 11/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98=20=EC=8B=9C=20=EB=AC=B8=EC=9E=90=C2=B7=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=B1=84=EB=84=90=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=9E=90=EB=8F=99=20=EC=8B=9C=EB=94=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PluginManager::activatePlugin 이 status=active 로 바꾸기 전에 activate 를 호출하므로, 그 시점엔 SeedChannelTemplatesListener 가 아직 등록되지 않아 재시딩이 sms·alimtalk 채널 template 을 붙이지 못했다(알림 설정 탭이 비어 토글도 미노출). activate 가 재시딩 전에 해당 리스너를 명시적으로 선등록하도록 수정. register 는 동일 프로세스 내 중복 등록을 막으므로 멱등하며, 코어는 수정하지 않는다. - plugin.php: activate 에 리스너 선등록 추가 - 회귀 테스트: activate 후 sms·alimtalk 템플릿 생성 검증 (fail→green) - CHANGELOG: working(1.0.0) Added 항목에 설치 시 자동 시딩 문구 흡수 --- .../sirsoft-message_bizppurio/CHANGELOG.md | 13 ++++- .../sirsoft-message_bizppurio/plugin.php | 11 ++++ .../ChannelTemplateSeedOnActivateTest.php | 55 +++++++++++++++++++ 3 files changed, 76 insertions(+), 3 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 0ea37664..6aa42a4c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -13,7 +13,7 @@ - 발송 결과(리포트)를 받을 수신 주소를 설정 화면에서 확인·복사할 수 있습니다. 이 주소를 비즈뿌리오에 등록하면 발송 결과가 이력에 자동 기록됩니다. - 비즈뿌리오 메시지 발송 기능에 대한 조회·관리 권한을 추가했습니다. 관련 화면은 플러그인 설정 페이지에서 진입합니다. - 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. -- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. +- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. 플러그인을 설치하면 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워져, 바로 켜고 편집할 수 있습니다. - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) - 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). @@ -21,6 +21,13 @@ - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. - 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다. - 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.) -- 본문에 넣을 수 있는 변수를 클릭하면 커서 위치에 바로 삽입되며, 알림 종류별로 제공되는 변수 예시를 참고표로 함께 안내합니다. -- 선택 항목(부가정보·미리보기 메시지·버튼·바로연결·대표 링크·템플릿 보안)을 체크해 필요한 것만 펼쳐 입력할 수 있습니다. +- 본문에 넣을 수 있는 변수를 회원가입·비밀번호 관련 알림별로 참고표에 정리해 항상 보여줍니다. 그 밖의 알림 변수는 해당 알림의 모듈/플러그인 설정에서 확인하도록 안내합니다. +- 선택 항목(부가정보·버튼·대표 링크·미리보기 메시지·템플릿 보안)을 필요한 것만 체크해 입력할 수 있습니다. +- 버튼은 최대 5개까지 추가할 수 있으며, 버튼 종류(웹링크·앱링크·채널추가·전화하기·배송조회 등)에 따라 필요한 입력란(모바일/PC 링크·전화번호·플러그인 ID 등)이 자동으로 나타납니다. 각 입력란에는 필수·선택 여부가 표시됩니다. +- 버튼 입력값을 카카오 규격에 맞게 검사합니다. 웹링크 주소는 http/https 형식, 앱링크는 안드로이드·iOS·모바일 중 2개 이상(전화연결 링크 불가), 전화하기 버튼명은 "전화 연결·고객센터 연결·상담원 연결" 중 하나, 대표 링크의 PC·모바일 주소는 웹 주소 형식이어야 합니다. 잘못 입력하면 어느 버튼의 어느 항목인지 알기 쉽게 안내합니다. +- 템플릿 보안(OTP)과 미리보기 메시지는 함께 쓸 수 없어, 한쪽을 켜면 다른 쪽 선택이 잠기고 그 이유를 안내합니다. +- 입력란마다 현재 글자 수와 최대 글자 수를 실시간으로 표시해, 카카오 규격을 넘지 않도록 돕습니다. +- 목록 화면과 등록 화면 상단에 템플릿 승인·검수 절차와 작성 규칙(정보성 메시지만 가능, 수신 근거 문구 권장, 등록 후에도 카카오 심사에서 반려될 수 있음)을 안내합니다. +- 등록 화면의 실시간 미리보기가 입력 폼 옆에 고정되어, 긴 폼을 스크롤하며 입력해도 미리보기를 계속 볼 수 있습니다. +- 템플릿 등록·수정이 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 반려 사유 등)를 화면에 그대로 표시합니다. - 템플릿은 [등록]으로 임시 저장하거나 [등록 후 검수요청]으로 등록과 동시에 카카오 검수를 요청할 수 있습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 16ec4181..db2777db 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\MessageBizppurio; use App\Enums\ExtensionOwnerType; use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; +use App\Extension\HookListenerRegistrar; use App\Extension\ModuleManager; use Database\Seeders\NotificationDefinitionSeeder; use Illuminate\Support\Facades\Log; @@ -50,11 +51,21 @@ class Plugin extends AbstractPlugin * 실패는 로그만 남기고 활성화 자체는 막지 않는다(발송·연동은 채널 등록만으로도 동작하며, * template 은 다음 정상 재시딩에도 수렴). * + * 리스너 선등록: PluginManager::activatePlugin() 은 DB status 를 active 로 바꾸기 + * '전에' 이 activate() 를 호출한다. 그런데 registerPluginHookListeners() 의 active 가드는 + * status=active 인 플러그인의 리스너만 등록하므로, 이 시점엔 SeedChannelTemplatesListener 가 + * 아직 등록되지 않았다. 그대로 재시딩하면 시딩 필터에 우리 리스너가 편승하지 못해 + * sms·alimtalk 채널 template 이 붙지 않는다(실서버 회귀). 따라서 재시딩 전에 이 리스너를 + * 명시적으로 선등록한다. register() 는 동일 process 내 중복 등록을 막으므로 멱등하다. + * * @return bool 활성화 성공 여부 */ public function activate(): bool { try { + // 재시딩이 편승할 시딩 필터 리스너를 status=active 전환 이전에 명시적으로 등록한다. + HookListenerRegistrar::register(SeedChannelTemplatesListener::class, $this->getIdentifier()); + app(NotificationDefinitionSeeder::class)->run(); app(ModuleManager::class)->resyncAllActiveDeclarativeArtifacts(); } catch (\Throwable $e) { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php new file mode 100644 index 00000000..608dafcf --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php @@ -0,0 +1,55 @@ +run(); + + $this->assertSame( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + '활성화 전에는 sms·alimtalk 채널 템플릿이 없어야 한다(설치 전 상태).' + ); + + // [실행] 정식 활성화 진입점. 이 안에서 리스너 선등록 → 재시딩이 채널을 증강해야 한다. + (new Plugin)->activate(); + + // [검증] 회원 대상 코어 알림(welcome 등)에 sms·alimtalk 템플릿이 생성됐다. + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'sms')->count(), + 'activate() 후 sms 채널 템플릿이 회원 알림에 생성돼야 한다.' + ); + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'alimtalk')->count(), + 'activate() 후 alimtalk 채널 템플릿이 회원 알림에 생성돼야 한다.' + ); + } +} From 92f36e86b12222359716e0b3044d4f5d2121d649 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 21 Jul 2026 18:28:36 +0900 Subject: [PATCH 12/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=84=20=EC=A1=B0=ED=9A=8C=20=EC=A0=84=EC=9A=A9?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 템플릿 등록·수정·삭제·검수·상태변경을 제거하고 목록·상태·내용 조회 + 알림 연결만 남긴다. 등록·관리는 비즈뿌리오 콘솔로 위임 — 콘솔이 이미 이미지메이커·AI생성·캐러셀을 잘 제공하고, 카카오 규격이 자주 바뀌어 등록 폼을 유지하면 규격을 영구히 쫓아가야 하는 유지보수 부채가 크기 때문이다. 이 플러그인의 고유 가치는 카카오 템플릿 ↔ G7 알림 연결(binding)이다. 주요 변경: - 라우트/컨트롤러/서비스에서 변경(manage) 메서드 전부 제거, 조회만 유지 - dev_sample 임시 개발코드 + FormRequest 3종 + 이미지 업로드 경로 제거 - 레이아웃: 등록 폼·상태변경 모달 제거, 상세는 내용만+[닫기], 목록 관리열은 [내용]만 - 환경설정 사용 전 준비 안내 + 목록 상단 콘솔 위임 안내(배지 의미색) 추가 - 안내 배너 중복 해소: 목록 조회 실패 배너에 ready 게이트를 걸어 설정 안내와 배타 표시 동반 변경(직전 세션 잔여, lang 얽힘으로 같은 커밋): - 연동 행 SMS 대체발송 배지(ON/OFF solid 색) 추가 --- docs/backend/api/README.md | 4 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 18 +- .../sirsoft-message_bizppurio/components.json | 10 + .../dist/js/plugin.iife.js | 2 +- .../dist/js/plugin.iife.js.map | 2 +- .../docs/api/README.md | 5 +- .../docs/api/alimtalk-templates.md | 511 -------- .../docs/api/templates.md | 75 ++ .../lang/en/activity_log.php | 36 +- .../lang/en/messages.php | 49 - .../lang/ko/activity_log.php | 35 +- .../lang/ko/messages.php | 49 - .../extensions/notification_row_footer.json | 29 +- .../layouts/alimtalk_templates.test.tsx | 284 ++-- .../layouts/plugin_settings.test.tsx | 13 +- .../resources/js/handlers/index.ts | 13 +- .../resources/js/handlers/insertVariable.ts | 103 -- .../js/handlers/uploadTemplateImage.ts | 98 -- .../resources/js/index.ts | 3 +- .../resources/lang/en.json | 424 +++--- .../resources/lang/ko.json | 424 +++--- .../layouts/admin/plugin_settings.json | 1168 +++++------------ .../Admin/AlimtalkTemplateController.php | 202 +-- .../Requests/StoreAlimtalkTemplateRequest.php | 111 -- .../UpdateAlimtalkTemplateRequest.php | 16 - .../Requests/UploadTemplateImageRequest.php | 53 - .../src/Services/AlimtalkTemplateService.php | 362 +---- .../src/Services/BizppurioKakaoApiClient.php | 39 - .../src/routes/api.php | 50 +- .../AlimtalkTemplateControllerTest.php | 201 +-- .../Services/AlimtalkTemplateServiceTest.php | 167 +-- .../Services/BizppurioKakaoApiClientTest.php | 27 - .../tests/scenarios/alimtalk-templates.yaml | 113 +- .../sirsoft-message_bizppurio/vite.config.ts | 2 +- 34 files changed, 1186 insertions(+), 3512 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/components.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 28077d63..b7ef5357 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 393 +- **확장 수**: 10 · **엔드포인트 수**: 384 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,7 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 5 / 21 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 6aa42a4c..0d028288 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -19,15 +19,9 @@ - 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). - 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다. - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. -- 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다. -- 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.) -- 본문에 넣을 수 있는 변수를 회원가입·비밀번호 관련 알림별로 참고표에 정리해 항상 보여줍니다. 그 밖의 알림 변수는 해당 알림의 모듈/플러그인 설정에서 확인하도록 안내합니다. -- 선택 항목(부가정보·버튼·대표 링크·미리보기 메시지·템플릿 보안)을 필요한 것만 체크해 입력할 수 있습니다. -- 버튼은 최대 5개까지 추가할 수 있으며, 버튼 종류(웹링크·앱링크·채널추가·전화하기·배송조회 등)에 따라 필요한 입력란(모바일/PC 링크·전화번호·플러그인 ID 등)이 자동으로 나타납니다. 각 입력란에는 필수·선택 여부가 표시됩니다. -- 버튼 입력값을 카카오 규격에 맞게 검사합니다. 웹링크 주소는 http/https 형식, 앱링크는 안드로이드·iOS·모바일 중 2개 이상(전화연결 링크 불가), 전화하기 버튼명은 "전화 연결·고객센터 연결·상담원 연결" 중 하나, 대표 링크의 PC·모바일 주소는 웹 주소 형식이어야 합니다. 잘못 입력하면 어느 버튼의 어느 항목인지 알기 쉽게 안내합니다. -- 템플릿 보안(OTP)과 미리보기 메시지는 함께 쓸 수 없어, 한쪽을 켜면 다른 쪽 선택이 잠기고 그 이유를 안내합니다. -- 입력란마다 현재 글자 수와 최대 글자 수를 실시간으로 표시해, 카카오 규격을 넘지 않도록 돕습니다. -- 목록 화면과 등록 화면 상단에 템플릿 승인·검수 절차와 작성 규칙(정보성 메시지만 가능, 수신 근거 문구 권장, 등록 후에도 카카오 심사에서 반려될 수 있음)을 안내합니다. -- 등록 화면의 실시간 미리보기가 입력 폼 옆에 고정되어, 긴 폼을 스크롤하며 입력해도 미리보기를 계속 볼 수 있습니다. -- 템플릿 등록·수정이 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 반려 사유 등)를 화면에 그대로 표시합니다. -- 템플릿은 [등록]으로 임시 저장하거나 [등록 후 검수요청]으로 등록과 동시에 카카오 검수를 요청할 수 있습니다. +- 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿의 목록·상태·내용을 확인할 수 있습니다. 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 하며, 이 화면은 목록·상태·내용 조회와 알림 연결을 담당합니다. 콘솔에서 등록한 템플릿도 이 목록에 그대로 표시되고 알림에 연결할 수 있습니다. +- 템플릿 목록을 상태(발송가능·검수중·반려·미검수·중지 등) 배지와 함께 보여주고, 상태·이름으로 필터·검색할 수 있습니다. 발송가능(승인) 상태의 템플릿만 알림에 연결해 발송할 수 있습니다. +- 각 템플릿의 [내용] 버튼으로 템플릿명·코드·카테고리·유형·본문·버튼을 담은 상세 내용을 확인할 수 있습니다. +- 목록 상단에 배지 의미(발송가능·검수중·미검수·반려)와 함께, 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 한다는 안내와 콘솔 바로가기를 제공합니다. +- 환경설정 탭에 카카오 알림톡·문자(SMS/LMS) 사용 전 준비 사항(비즈뿌리오 가입·API 승인·발신프로필 등록·발신번호 사전 등록)을 안내하는 박스를 추가했습니다. +- 템플릿 목록·상세 조회가 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 발신프로필 미설정 등)를 화면에 그대로 표시합니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/components.json b/plugins/_bundled/sirsoft-message_bizppurio/components.json new file mode 100644 index 00000000..42c0ba08 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/components.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "identifier": "sirsoft-message_bizppurio", + "version": "1.0.0", + "components": { + "basic": [], + "composite": [], + "layout": [] + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js index 057cb622..991b4d28 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js @@ -1,2 +1,2 @@ -var SirsoftMessageBizppurio=(function(f){"use strict";const w=window.G7Core?.createLogger?.("MessageBizppurio:InsertVariable")??{log:(...e)=>console.log("[MessageBizppurio:InsertVariable]",...e),warn:(...e)=>console.warn("[MessageBizppurio:InsertVariable]",...e),error:(...e)=>console.error("[MessageBizppurio:InsertVariable]",...e)},y="templateContent",A="templateForm.templateContent";function C(e,o){const t=window.G7Core,g=e.params??{},n=String(g.variable??"").trim(),a=String(g.target??y),s=String(g.statePath??A);if(n===""){w.warn("삽입할 변수명이 지정되지 않았습니다.");return}if(!t?.state?.getLocal||!t?.state?.setLocal){w.error("G7Core.state API 를 사용할 수 없습니다.");return}const d=o?.state??t.state.getLocal()??{},r=String(P(d,s)??""),i=`#{${n}}`,l=document.querySelector(`textarea[name="${a}"], input[name="${a}"]`),u=(l?.value??"")===r,b=u?l?.selectionStart??r.length:r.length,E=u?l?.selectionEnd??r.length:r.length,M=r.slice(0,b)+i+r.slice(E);if(t.state.setLocal({[s]:M}),l){const $=b+i.length;requestAnimationFrame(()=>{try{l.focus(),l.setSelectionRange($,$)}catch{}})}w.log(`변수 삽입: ${i} → ${s}`)}function P(e,o){return o.split(".").reduce((t,g)=>t?.[g],e)}const p=window.G7Core?.createLogger?.("MessageBizppurio:UploadImage")??{log:(...e)=>console.log("[MessageBizppurio:UploadImage]",...e),warn:(...e)=>console.warn("[MessageBizppurio:UploadImage]",...e),error:(...e)=>console.error("[MessageBizppurio:UploadImage]",...e)},G="/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image";async function z(e,o){const t=window.G7Core;if(!t?.state?.setLocal){p.error("G7Core.state.setLocal 을 사용할 수 없습니다.");return}const n=(o?.event??e?.event)?.target,a=n?.files?.[0];if(a){t.state.setLocal({imageUploading:!0,imageUploadError:null});try{const s=localStorage.getItem("auth_token")??"",d=new FormData;d.append("image",a,a.name);const r=await fetch(G,{method:"POST",headers:{Accept:"application/json",...s?{Authorization:`Bearer ${s}`}:{}},body:d}),i=await r.json().catch(()=>({}));if(!r.ok||i?.success===!1){const u=i?.errors?.kakao_message||i?.message||"이미지 업로드에 실패했습니다.";t.state.setLocal({imageUploading:!1,imageUploadError:u}),p.warn("이미지 업로드 실패:",u);return}const l=i?.data?.image_url??"",U=i?.data?.image_name??a.name;t.state.setLocal({imageUploading:!1,imageUploadError:null,"templateForm.templateImageUrl":l,"templateForm.templateImageName":U}),p.log("이미지 업로드 완료:",l)}catch(s){t.state.setLocal({imageUploading:!1,imageUploadError:String(s)}),p.error("이미지 업로드 예외:",s)}finally{n&&(n.value="")}}}const m={insertVariable:C,uploadTemplateImage:z},c="sirsoft-message_bizppurio",h=window.G7Core?.createLogger?.(`Plugin:${c}`)??{log:(...e)=>console.log(`[Plugin:${c}]`,...e),warn:(...e)=>console.warn(`[Plugin:${c}]`,...e),error:(...e)=>console.error(`[Plugin:${c}]`,...e)};function I(e){Object.entries(m).forEach(([o,t])=>{e.registerHandler(`${c}.${o}`,t,{category:"plugin",source:c})}),h.log(`${Object.keys(m).length} handler(s) registered:`,Object.keys(m).map(o=>`${c}.${o}`))}function S(e){const o=window.G7Core?.getActionDispatcher?.();if(o){I(o);return}if(!e){h.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let t=0;const g=50,n=()=>{const a=window.G7Core?.getActionDispatcher?.();if(a){I(a);return}++t<=g?setTimeout(n,100):h.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};n()}function L(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>S(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();S(!e)}}return L(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:c,handlers:Object.keys(m),initPlugin:L}),f.initPlugin=L,Object.defineProperty(f,Symbol.toStringTag,{value:"Module"}),f})({}); +var SirsoftMessageBizppurio=(function(r){"use strict";const i={},t="sirsoft-message_bizppurio",o=window.G7Core?.createLogger?.(`Plugin:${t}`)??{log:(...e)=>console.log(`[Plugin:${t}]`,...e),warn:(...e)=>console.warn(`[Plugin:${t}]`,...e),error:(...e)=>console.error(`[Plugin:${t}]`,...e)};function a(e){Object.entries(i).forEach(([n,c])=>{e.registerHandler(`${t}.${n}`,c,{category:"plugin",source:t})}),o.log(`${Object.keys(i).length} handler(s) registered:`,Object.keys(i).map(n=>`${t}.${n}`))}function g(e){const n=window.G7Core?.getActionDispatcher?.();if(n){a(n);return}if(!e){o.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let c=0;const l=50,u=()=>{const d=window.G7Core?.getActionDispatcher?.();if(d){a(d);return}++c<=l?setTimeout(u,100):o.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};u()}function s(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>g(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();g(!e)}}return s(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:t,handlers:Object.keys(i),initPlugin:s}),r.initPlugin=s,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}),r})({}); //# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map index 63320d32..b9acd530 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map +++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js.map @@ -1 +1 @@ -{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/insertVariable.ts","../../resources/js/handlers/uploadTemplateImage.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * insertVariable 핸들러\n *\n * 알림톡 템플릿 등록/수정 화면에서 \"사용 가능 변수\" 버튼을 클릭하면, 대상 본문의 현재\n * 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.`)를 SSoT\n * 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌\n * (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다.\n *\n * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.\n *\n * 레이아웃 JSON 사용 예:\n * {\n * \"handler\": \"sirsoft-message_bizppurio.insertVariable\",\n * \"params\": { \"variable\": \"name\", \"target\": \"templateContent\", \"statePath\": \"templateForm.templateContent\" }\n * }\n */\n\nimport type { ActionContext, ActionWithParams } from '../types';\n\nconst logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {\n log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),\n warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),\n error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),\n};\n\n/** 삽입 대상 본문 필드(name) 기본값 */\nconst DEFAULT_TARGET = 'templateContent';\n\n/** 상태 경로 기본값 (_local 하위) */\nconst DEFAULT_STATE_PATH = 'templateForm.templateContent';\n\n/**\n * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.\n *\n * @param action 액션 객체(params.variable, params.target, params.statePath)\n * @param context 액션 컨텍스트(state 스냅샷)\n */\nexport function insertVariableHandler(\n action: ActionWithParams,\n context: ActionContext,\n): void {\n const G7Core = (window as any).G7Core;\n const params = action.params ?? {};\n const variable = String(params.variable ?? '').trim();\n const target = String(params.target ?? DEFAULT_TARGET);\n const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);\n\n if (variable === '') {\n logger.warn('삽입할 변수명이 지정되지 않았습니다.');\n return;\n }\n\n if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) {\n logger.error('G7Core.state API 를 사용할 수 없습니다.');\n return;\n }\n\n // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal.\n const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {};\n const current = String(readPath(localState, statePath) ?? '');\n\n const token = `#{${variable}}`;\n\n // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.\n const field = document.querySelector(\n `textarea[name=\"${target}\"], input[name=\"${target}\"]`,\n );\n const domValue = field?.value ?? '';\n // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.\n const inSync = domValue === current;\n const start = inSync ? (field?.selectionStart ?? current.length) : current.length;\n const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;\n\n const next = current.slice(0, start) + token + current.slice(end);\n\n G7Core.state.setLocal({ [statePath]: next });\n\n // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).\n if (field) {\n const caret = start + token.length;\n requestAnimationFrame(() => {\n try {\n field.focus();\n field.setSelectionRange(caret, caret);\n } catch {\n // setSelectionRange 미지원 input — 무시\n }\n });\n }\n\n logger.log(`변수 삽입: ${token} → ${statePath}`);\n}\n\n/**\n * dot notation 경로로 객체에서 값을 읽습니다. (예: \"templateForm.templateContent\")\n *\n * @param obj 대상 객체\n * @param path dot 경로\n * @return 경로 값(없으면 undefined)\n */\nfunction readPath(obj: Record, path: string): unknown {\n return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);\n}\n","/**\n * uploadTemplateImage 핸들러\n *\n * 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의\n * 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을\n * `_local.templateForm.templateImageUrl` 에, 파일명을 `templateImageName` 에 설정한다.\n *\n * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다.\n * 진행 상태는 `_local.imageUploading`, 오류는 `_local.imageUploadError` 로 노출한다.\n *\n * 레이아웃 JSON 사용 예:\n * {\n * \"type\": \"change\",\n * \"handler\": \"sirsoft-message_bizppurio.uploadTemplateImage\"\n * }\n * (대상 file input 의 change 이벤트에 바인딩; $event.target.files[0] 를 사용)\n */\n\nimport type { ActionContext, ActionWithParams } from '../types';\n\nconst logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? {\n log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args),\n warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args),\n error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args),\n};\n\nconst UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image';\n\n/**\n * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다.\n *\n * @param action 액션 객체(미사용 params)\n * @param context 액션 컨텍스트($event 로 파일 접근)\n */\nexport async function uploadTemplateImageHandler(\n action: ActionWithParams,\n context: ActionContext,\n): Promise {\n const G7Core = (window as any).G7Core;\n if (!G7Core?.state?.setLocal) {\n logger.error('G7Core.state.setLocal 을 사용할 수 없습니다.');\n return;\n }\n\n const event = (context?.event ?? (action as any)?.event) as Event | undefined;\n const input = event?.target as HTMLInputElement | undefined;\n const file = input?.files?.[0];\n\n if (!file) {\n return;\n }\n\n G7Core.state.setLocal({ imageUploading: true, imageUploadError: null });\n\n try {\n const token = localStorage.getItem('auth_token') ?? '';\n const form = new FormData();\n form.append('image', file, file.name);\n\n const res = await fetch(UPLOAD_URL, {\n method: 'POST',\n headers: {\n Accept: 'application/json',\n ...(token ? { Authorization: `Bearer ${token}` } : {}),\n },\n body: form,\n });\n\n const json = await res.json().catch(() => ({}));\n\n if (!res.ok || json?.success === false) {\n const message = json?.errors?.kakao_message || json?.message || '이미지 업로드에 실패했습니다.';\n G7Core.state.setLocal({ imageUploading: false, imageUploadError: message });\n logger.warn('이미지 업로드 실패:', message);\n return;\n }\n\n const url = json?.data?.image_url ?? '';\n const name = json?.data?.image_name ?? file.name;\n\n G7Core.state.setLocal({\n imageUploading: false,\n imageUploadError: null,\n 'templateForm.templateImageUrl': url,\n 'templateForm.templateImageName': name,\n });\n\n logger.log('이미지 업로드 완료:', url);\n } catch (e) {\n G7Core.state.setLocal({ imageUploading: false, imageUploadError: String(e) });\n logger.error('이미지 업로드 예외:', e);\n } finally {\n // 같은 파일 재선택이 가능하도록 input 값 초기화\n if (input) {\n input.value = '';\n }\n }\n}\n","/**\n * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.\n *\n * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인\n * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable\n */\n\nimport { insertVariableHandler } from './insertVariable';\nimport { uploadTemplateImageHandler } from './uploadTemplateImage';\n\nexport const handlerMap = {\n insertVariable: insertVariableHandler,\n uploadTemplateImage: uploadTemplateImageHandler,\n} as const;\n","/**\n * sirsoft-message_bizppurio 플러그인 엔트리포인트.\n *\n * 플러그인 활성화 시 자동 로드되어 커스텀 핸들러(insertVariable)를 ActionDispatcher 에\n * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.\n */\n\nimport { handlerMap } from './handlers';\n\nconst PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';\n\nconst logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {\n log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n};\n\n/**\n * handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다.\n *\n * @param dispatcher 코어 ActionDispatcher 인스턴스\n */\nfunction register(dispatcher: any): void {\n Object.entries(handlerMap).forEach(([name, handler]) => {\n dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, {\n category: 'plugin',\n source: PLUGIN_IDENTIFIER,\n });\n });\n\n logger.log(\n `${Object.keys(handlerMap).length} handler(s) registered:`,\n Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`),\n );\n}\n\n/**\n * ActionDispatcher 준비 후 핸들러를 등록합니다.\n *\n * 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다.\n *\n * @param retry ActionDispatcher 부재 시 재시도 여부\n */\nfunction registerHandlers(retry: boolean): void {\n const dispatcher = (window as any).G7Core?.getActionDispatcher?.();\n\n if (dispatcher) {\n register(dispatcher);\n return;\n }\n\n if (!retry) {\n logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.');\n return;\n }\n\n let count = 0;\n const max = 50; // 최대 5초 (50 * 100ms)\n const tick = () => {\n const found = (window as any).G7Core?.getActionDispatcher?.();\n if (found) {\n register(found);\n return;\n }\n if (++count <= max) {\n setTimeout(tick, 100);\n } else {\n logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.');\n }\n };\n tick();\n}\n\n/**\n * 플러그인 초기화 — DOM 준비 후 핸들러 등록.\n */\nexport function initPlugin(): void {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => registerHandlers(true));\n } else {\n const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();\n registerHandlers(!hasDispatcher);\n }\n}\n\ninitPlugin();\n\nif (typeof window !== 'undefined') {\n (window as any).__SirsoftMessageBizppurio = {\n identifier: PLUGIN_IDENTIFIER,\n handlers: Object.keys(handlerMap),\n initPlugin,\n };\n}"],"names":["logger","args","DEFAULT_TARGET","DEFAULT_STATE_PATH","insertVariableHandler","action","context","G7Core","params","variable","target","statePath","localState","current","readPath","token","field","inSync","start","end","next","caret","obj","path","acc","key","UPLOAD_URL","uploadTemplateImageHandler","input","file","form","res","json","message","url","name","e","handlerMap","PLUGIN_IDENTIFIER","register","dispatcher","handler","registerHandlers","retry","count","max","tick","found","initPlugin","hasDispatcher"],"mappings":"sDAmBA,MAAMA,EAAW,OAAe,QAAQ,eAAe,iCAAiC,GAAM,CAC1F,IAAK,IAAIC,IAAoB,QAAQ,IAAI,oCAAqC,GAAGA,CAAI,EACrF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,oCAAqC,GAAGA,CAAI,EACvF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,oCAAqC,GAAGA,CAAI,CAC7F,EAGMC,EAAiB,kBAGjBC,EAAqB,+BAQpB,SAASC,EACZC,EACAC,EACI,CACJ,MAAMC,EAAU,OAAe,OACzBC,EAASH,EAAO,QAAU,CAAA,EAC1BI,EAAW,OAAOD,EAAO,UAAY,EAAE,EAAE,KAAA,EACzCE,EAAS,OAAOF,EAAO,QAAUN,CAAc,EAC/CS,EAAY,OAAOH,EAAO,WAAaL,CAAkB,EAE/D,GAAIM,IAAa,GAAI,CACjBT,EAAO,KAAK,sBAAsB,EAClC,MACJ,CAEA,GAAI,CAACO,GAAQ,OAAO,UAAY,CAACA,GAAQ,OAAO,SAAU,CACtDP,EAAO,MAAM,gCAAgC,EAC7C,MACJ,CAGA,MAAMY,EAAcN,GAAiB,OAASC,EAAO,MAAM,SAAA,GAAc,CAAA,EACnEM,EAAU,OAAOC,EAASF,EAAYD,CAAS,GAAK,EAAE,EAEtDI,EAAQ,KAAKN,CAAQ,IAGrBO,EAAQ,SAAS,cACnB,kBAAkBN,CAAM,mBAAmBA,CAAM,IAAA,EAI/CO,GAFWD,GAAO,OAAS,MAELH,EACtBK,EAAQD,EAAUD,GAAO,gBAAkBH,EAAQ,OAAUA,EAAQ,OACrEM,EAAMF,EAAUD,GAAO,cAAgBH,EAAQ,OAAUA,EAAQ,OAEjEO,EAAOP,EAAQ,MAAM,EAAGK,CAAK,EAAIH,EAAQF,EAAQ,MAAMM,CAAG,EAKhE,GAHAZ,EAAO,MAAM,SAAS,CAAE,CAACI,CAAS,EAAGS,EAAM,EAGvCJ,EAAO,CACP,MAAMK,EAAQH,EAAQH,EAAM,OAC5B,sBAAsB,IAAM,CACxB,GAAI,CACAC,EAAM,MAAA,EACNA,EAAM,kBAAkBK,EAAOA,CAAK,CACxC,MAAQ,CAER,CACJ,CAAC,CACL,CAEArB,EAAO,IAAI,UAAUe,CAAK,MAAMJ,CAAS,EAAE,CAC/C,CASA,SAASG,EAASQ,EAA0BC,EAAuB,CAC/D,OAAOA,EAAK,MAAM,GAAG,EAAE,OAAY,CAACC,EAAKC,IAAmCD,IAAIC,CAAG,EAAIH,CAAG,CAC9F,CClFA,MAAMtB,EAAW,OAAe,QAAQ,eAAe,8BAA8B,GAAM,CACvF,IAAK,IAAIC,IAAoB,QAAQ,IAAI,iCAAkC,GAAGA,CAAI,EAClF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,iCAAkC,GAAGA,CAAI,EACpF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,iCAAkC,GAAGA,CAAI,CAC1F,EAEMyB,EAAa,wEAQnB,eAAsBC,EAClBtB,EACAC,EACa,CACb,MAAMC,EAAU,OAAe,OAC/B,GAAI,CAACA,GAAQ,OAAO,SAAU,CAC1BP,EAAO,MAAM,qCAAqC,EAClD,MACJ,CAGA,MAAM4B,GADStB,GAAS,OAAUD,GAAgB,QAC7B,OACfwB,EAAOD,GAAO,QAAQ,CAAC,EAE7B,GAAKC,EAIL,CAAAtB,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAM,iBAAkB,KAAM,EAEtE,GAAI,CACA,MAAMQ,EAAQ,aAAa,QAAQ,YAAY,GAAK,GAC9Ce,EAAO,IAAI,SACjBA,EAAK,OAAO,QAASD,EAAMA,EAAK,IAAI,EAEpC,MAAME,EAAM,MAAM,MAAML,EAAY,CAChC,OAAQ,OACR,QAAS,CACL,OAAQ,mBACR,GAAIX,EAAQ,CAAE,cAAe,UAAUA,CAAK,IAAO,CAAA,CAAC,EAExD,KAAMe,CAAA,CACT,EAEKE,EAAO,MAAMD,EAAI,KAAA,EAAO,MAAM,KAAO,CAAA,EAAG,EAE9C,GAAI,CAACA,EAAI,IAAMC,GAAM,UAAY,GAAO,CACpC,MAAMC,EAAUD,GAAM,QAAQ,eAAiBA,GAAM,SAAW,mBAChEzB,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAO,iBAAkB0B,EAAS,EAC1EjC,EAAO,KAAK,cAAeiC,CAAO,EAClC,MACJ,CAEA,MAAMC,EAAMF,GAAM,MAAM,WAAa,GAC/BG,EAAOH,GAAM,MAAM,YAAcH,EAAK,KAE5CtB,EAAO,MAAM,SAAS,CAClB,eAAgB,GAChB,iBAAkB,KAClB,gCAAiC2B,EACjC,iCAAkCC,CAAA,CACrC,EAEDnC,EAAO,IAAI,cAAekC,CAAG,CACjC,OAASE,EAAG,CACR7B,EAAO,MAAM,SAAS,CAAE,eAAgB,GAAO,iBAAkB,OAAO6B,CAAC,EAAG,EAC5EpC,EAAO,MAAM,cAAeoC,CAAC,CACjC,QAAA,CAEQR,IACAA,EAAM,MAAQ,GAEtB,EACJ,CCvFO,MAAMS,EAAa,CACtB,eAAgBjC,EAChB,oBAAqBuB,CACzB,ECJMW,EAAoB,4BAEpBtC,EAAW,OAAe,QAAQ,eAAe,UAAUsC,CAAiB,EAAE,GAAM,CACtF,IAAK,IAAIrC,IAAoB,QAAQ,IAAI,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,EACjF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,EACnF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,WAAWqC,CAAiB,IAAK,GAAGrC,CAAI,CACzF,EAOA,SAASsC,EAASC,EAAuB,CACrC,OAAO,QAAQH,CAAU,EAAE,QAAQ,CAAC,CAACF,EAAMM,CAAO,IAAM,CACpDD,EAAW,gBAAgB,GAAGF,CAAiB,IAAIH,CAAI,GAAIM,EAAS,CAChE,SAAU,SACV,OAAQH,CAAA,CACX,CACL,CAAC,EAEDtC,EAAO,IACH,GAAG,OAAO,KAAKqC,CAAU,EAAE,MAAM,0BACjC,OAAO,KAAKA,CAAU,EAAE,OAAY,GAAGC,CAAiB,IAAIH,CAAI,EAAE,CAAA,CAE1E,CASA,SAASO,EAAiBC,EAAsB,CAC5C,MAAMH,EAAc,OAAe,QAAQ,sBAAA,EAE3C,GAAIA,EAAY,CACZD,EAASC,CAAU,EACnB,MACJ,CAEA,GAAI,CAACG,EAAO,CACR3C,EAAO,KAAK,2CAA2C,EACvD,MACJ,CAEA,IAAI4C,EAAQ,EACZ,MAAMC,EAAM,GACNC,EAAO,IAAM,CACf,MAAMC,EAAS,OAAe,QAAQ,sBAAA,EACtC,GAAIA,EAAO,CACPR,EAASQ,CAAK,EACd,MACJ,CACI,EAAEH,GAASC,EACX,WAAWC,EAAM,GAAG,EAEpB9C,EAAO,MAAM,0CAA0C,CAE/D,EACA8C,EAAA,CACJ,CAKO,SAASE,GAAmB,CAC/B,GAAI,SAAS,aAAe,UACxB,SAAS,iBAAiB,mBAAoB,IAAMN,EAAiB,EAAI,CAAC,MACvE,CACH,MAAMO,EAAgB,CAAC,CAAE,OAAe,QAAQ,sBAAA,EAChDP,EAAiB,CAACO,CAAa,CACnC,CACJ,CAEA,OAAAD,EAAA,EAEI,OAAO,OAAW,MACjB,OAAe,0BAA4B,CACxC,WAAYV,EACZ,SAAU,OAAO,KAAKD,CAAU,EAChC,WAAAW,CAAA"} \ No newline at end of file +{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.\n *\n * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인\n * 식별자가 네임스페이스로 접두된다.\n *\n * 현재 등록 핸들러 없음(알림톡 템플릿 등록·이미지 업로드 제거로 uploadTemplateImage 제거).\n * 커스텀 핸들러 추가 시 이 맵에 등록한다.\n */\n\nexport const handlerMap = {} as const;","/**\n * sirsoft-message_bizppurio 플러그인 엔트리포인트.\n *\n * 플러그인 활성화 시 자동 로드되어 handlerMap 의 커스텀 핸들러를 ActionDispatcher 에\n * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.\n * (현재 등록 핸들러 없음 — 필요 시 handlers/index.ts 에 추가.)\n */\n\nimport { handlerMap } from './handlers';\n\nconst PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';\n\nconst logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {\n log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),\n};\n\n/**\n * handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다.\n *\n * @param dispatcher 코어 ActionDispatcher 인스턴스\n */\nfunction register(dispatcher: any): void {\n Object.entries(handlerMap).forEach(([name, handler]) => {\n dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, {\n category: 'plugin',\n source: PLUGIN_IDENTIFIER,\n });\n });\n\n logger.log(\n `${Object.keys(handlerMap).length} handler(s) registered:`,\n Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`),\n );\n}\n\n/**\n * ActionDispatcher 준비 후 핸들러를 등록합니다.\n *\n * 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다.\n *\n * @param retry ActionDispatcher 부재 시 재시도 여부\n */\nfunction registerHandlers(retry: boolean): void {\n const dispatcher = (window as any).G7Core?.getActionDispatcher?.();\n\n if (dispatcher) {\n register(dispatcher);\n return;\n }\n\n if (!retry) {\n logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.');\n return;\n }\n\n let count = 0;\n const max = 50; // 최대 5초 (50 * 100ms)\n const tick = () => {\n const found = (window as any).G7Core?.getActionDispatcher?.();\n if (found) {\n register(found);\n return;\n }\n if (++count <= max) {\n setTimeout(tick, 100);\n } else {\n logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.');\n }\n };\n tick();\n}\n\n/**\n * 플러그인 초기화 — DOM 준비 후 핸들러 등록.\n */\nexport function initPlugin(): void {\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', () => registerHandlers(true));\n } else {\n const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();\n registerHandlers(!hasDispatcher);\n }\n}\n\ninitPlugin();\n\nif (typeof window !== 'undefined') {\n (window as any).__SirsoftMessageBizppurio = {\n identifier: PLUGIN_IDENTIFIER,\n handlers: Object.keys(handlerMap),\n initPlugin,\n };\n}"],"names":["handlerMap","PLUGIN_IDENTIFIER","logger","args","register","dispatcher","name","handler","registerHandlers","retry","count","max","tick","found","initPlugin","hasDispatcher"],"mappings":"sDAUO,MAAMA,EAAa,CAAA,ECApBC,EAAoB,4BAEpBC,EAAW,OAAe,QAAQ,eAAe,UAAUD,CAAiB,EAAE,GAAM,CACtF,IAAK,IAAIE,IAAoB,QAAQ,IAAI,WAAWF,CAAiB,IAAK,GAAGE,CAAI,EACjF,KAAM,IAAIA,IAAoB,QAAQ,KAAK,WAAWF,CAAiB,IAAK,GAAGE,CAAI,EACnF,MAAO,IAAIA,IAAoB,QAAQ,MAAM,WAAWF,CAAiB,IAAK,GAAGE,CAAI,CACzF,EAOA,SAASC,EAASC,EAAuB,CACrC,OAAO,QAAQL,CAAU,EAAE,QAAQ,CAAC,CAACM,EAAMC,CAAO,IAAM,CACpDF,EAAW,gBAAgB,GAAGJ,CAAiB,IAAIK,CAAI,GAAIC,EAAS,CAChE,SAAU,SACV,OAAQN,CAAA,CACX,CACL,CAAC,EAEDC,EAAO,IACH,GAAG,OAAO,KAAKF,CAAU,EAAE,MAAM,0BACjC,OAAO,KAAKA,CAAU,EAAE,OAAY,GAAGC,CAAiB,IAAIK,CAAI,EAAE,CAAA,CAE1E,CASA,SAASE,EAAiBC,EAAsB,CAC5C,MAAMJ,EAAc,OAAe,QAAQ,sBAAA,EAE3C,GAAIA,EAAY,CACZD,EAASC,CAAU,EACnB,MACJ,CAEA,GAAI,CAACI,EAAO,CACRP,EAAO,KAAK,2CAA2C,EACvD,MACJ,CAEA,IAAIQ,EAAQ,EACZ,MAAMC,EAAM,GACNC,EAAO,IAAM,CACf,MAAMC,EAAS,OAAe,QAAQ,sBAAA,EACtC,GAAIA,EAAO,CACPT,EAASS,CAAK,EACd,MACJ,CACI,EAAEH,GAASC,EACX,WAAWC,EAAM,GAAG,EAEpBV,EAAO,MAAM,0CAA0C,CAE/D,EACAU,EAAA,CACJ,CAKO,SAASE,GAAmB,CAC/B,GAAI,SAAS,aAAe,UACxB,SAAS,iBAAiB,mBAAoB,IAAMN,EAAiB,EAAI,CAAC,MACvE,CACH,MAAMO,EAAgB,CAAC,CAAE,OAAe,QAAQ,sBAAA,EAChDP,EAAiB,CAACO,CAAa,CACnC,CACJ,CAEA,OAAAD,EAAA,EAEI,OAAO,OAAW,MACjB,OAAe,0BAA4B,CACxC,WAAYb,EACZ,SAAU,OAAO,KAAKD,CAAU,EAChC,WAAAc,CAAA"} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index e6cf7927..a423a3be 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,14 +4,15 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 5 · **엔드포인트 수**: 21 +- **문서 수**: 6 · **엔드포인트 수**: 12 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | -| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 | +| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 4 | | [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 | | [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 | | [report.md](report.md) | `report` | 1 | +| [templates.md](templates.md) | `templates` | 1 | | [webhook.md](webhook.md) | `webhook` | 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md index a1b2e664..00559669 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -56,89 +56,6 @@ Authorization: Bearer {YOUR_TOKEN} **설명** -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.store` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@store` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateName | body | string | 예 | max 200 | | -| templateContent | body | string | 예 | max 1300 | | -| categoryCode | body | string | 예 | max 20 | | -| templateEmphasizeType | body | string | 예 | — | | -| templateCode | body | string | 아니오 | max 30 | | -| templateTitle | body | string | 아니오 | max 23 | | -| templateSubtitle | body | string | 아니오 | max 18 | | -| templateImageName | body | string | 아니오 | max 255 | | -| templateImageUrl | body | string | 아니오 | max 500 | | -| templatePreviewMessage | body | string | 아니오 | max 40 | | -| templateExtra | body | string | 아니오 | max 500 | | -| securityFlag | body | boolean | 아니오 | — | | -| requestInspection | body | boolean | 아니오 | — | | -| buttons | body | array | 아니오 | max 5 | | -| quickReplies | body | array | 아니오 | max 10 | | -| templateRepresentLink | body | array | 아니오 | — | | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -Content-Type: application/json - -{ - "templateName": "예시 이름", - "templateContent": "예시 내용입니다.", - "categoryCode": "예시값", - "templateEmphasizeType": "예시값", - "templateCode": "예시값", - "templateTitle": "예시 제목", - "templateSubtitle": "예시 제목", - "templateImageName": "예시 이름", - "templateImageUrl": "https://example.com", - "templatePreviewMessage": "예시값", - "templateExtra": "예시값", - "securityFlag": true, - "requestInspection": true, - "buttons": [ - "예시값" - ], - "quickReplies": [ - "예시값" - ], - "templateRepresentLink": [ - "예시값" - ] -} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | - - - -**설명** - - ### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories - **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories` @@ -178,56 +95,6 @@ Authorization: Bearer {YOUR_TOKEN} **설명** -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.image` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@uploadImage` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| image | body | file | 예 | max 500 | | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -Content-Type: multipart/form-data; boundary=----G7ExampleBoundary - -------G7ExampleBoundary -Content-Disposition: form-data; name="image"; filename="example.pdf" -Content-Type: application/octet-stream - -(바이너리 파일 내용) -------G7ExampleBoundary-- -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | - - - -**설명** - - ### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles - **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles` @@ -267,48 +134,6 @@ Authorization: Bearer {YOUR_TOKEN} **설명** -### DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.destroy` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@destroy` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | - -**요청 예시** - -```http -DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - ### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} - **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show` @@ -351,339 +176,3 @@ Authorization: Bearer {YOUR_TOKEN} **설명** -### PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.update` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@update` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | -| templateName | body | string | 예 | max 200 | | -| templateContent | body | string | 예 | max 1300 | | -| categoryCode | body | string | 예 | max 20 | | -| templateEmphasizeType | body | string | 예 | — | | -| templateTitle | body | string | 아니오 | max 23 | | -| templateSubtitle | body | string | 아니오 | max 18 | | -| templateImageName | body | string | 아니오 | max 255 | | -| templateImageUrl | body | string | 아니오 | max 500 | | -| templatePreviewMessage | body | string | 아니오 | max 40 | | -| templateExtra | body | string | 아니오 | max 500 | | -| securityFlag | body | boolean | 아니오 | — | | -| requestInspection | body | boolean | 아니오 | — | | -| buttons | body | array | 아니오 | max 5 | | -| quickReplies | body | array | 아니오 | max 10 | | -| templateRepresentLink | body | array | 아니오 | — | | - -**요청 예시** - -```http -PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -Content-Type: application/json - -{ - "templateName": "예시 이름", - "templateContent": "예시 내용입니다.", - "categoryCode": "예시값", - "templateEmphasizeType": "예시값", - "templateCode": "예시값", - "templateTitle": "예시 제목", - "templateSubtitle": "예시 제목", - "templateImageName": "예시 이름", - "templateImageUrl": "https://example.com", - "templatePreviewMessage": "예시값", - "templateExtra": "예시값", - "securityFlag": true, - "requestInspection": true, - "buttons": [ - "예시값" - ], - "quickReplies": [ - "예시값" - ], - "templateRepresentLink": [ - "예시값" - ] -} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-approval` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelApproval` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-request` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelRequest` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.release` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@release` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.request` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@requestInspection` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.reuse` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@reuse` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - -### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop - -- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.stop` -- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@stop` -- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` - -**요청 파라미터** - -| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | -| --- | --- | --- | --- | --- | --- | -| templateCode | path | string | 예 | — | 대상 template code의 식별자 | - -**요청 예시** - -```http -POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop HTTP/1.1 -Host: api.example.com -Accept: application/json -Authorization: Bearer {YOUR_TOKEN} -``` - -**응답 필드** (`data` 내부) - - - -**응답 예시** - - - -**에러 응답** - -| 상태코드 | 의미 | 발생 조건 | -| --- | --- | --- | -| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | -| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | -| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 | - - - -**설명** - - diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md new file mode 100644 index 00000000..6f360c96 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md @@ -0,0 +1,75 @@ +# Templates API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Templates 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.readiness` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| api_key_set | boolean | `false` | | +| sender_key_set | boolean | `false` | | +| ready | boolean | `false` | | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "data": { + "api_key_set": "{MASKED}", + "sender_key_set": false, + "ready": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php index 606ec5c4..d6b0cae2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php @@ -1,34 +1,10 @@ [ - 'sirsoft-message_bizppurio' => [ - 'alimtalk_template' => [ - 'create' => 'Register alimtalk template', - 'update' => 'Update alimtalk template', - 'delete' => 'Delete alimtalk template', - 'request' => 'Request alimtalk template inspection', - 'cancel_request' => 'Cancel alimtalk template inspection request', - 'stop' => 'Stop alimtalk template', - 'reuse' => 'Resume alimtalk template', - 'cancel_approval' => 'Cancel alimtalk template approval', - 'release' => 'Release alimtalk template from dormancy', - ], - ], - ], - - // Description bodies — standard pattern activity_log.description.X (resolved by DescriptionResolver). - 'description' => [ - 'alimtalk_template_create' => 'Registered alimtalk template (:template_name)', - 'alimtalk_template_update' => 'Updated alimtalk template (:template_code)', - 'alimtalk_template_delete' => 'Deleted alimtalk template (:template_code)', - 'alimtalk_template_request' => 'Requested alimtalk template inspection (:template_code)', - 'alimtalk_template_cancel_request' => 'Canceled alimtalk template inspection request (:template_code)', - 'alimtalk_template_stop' => 'Stopped alimtalk template (:template_code)', - 'alimtalk_template_reuse' => 'Resumed alimtalk template (:template_code)', - 'alimtalk_template_cancel_approval' => 'Canceled alimtalk template approval (:template_code)', - 'alimtalk_template_release' => 'Released alimtalk template from dormancy (:template_code)', - ], + 'action' => [], + 'description' => [], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index e64e04d4..8bbde0f2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -76,55 +76,6 @@ return [ 'sender_key_missing' => 'Please set the alimtalk sender profile key first.', ], - // Alimtalk template management (Phase 5) - 'template' => [ - // Action result messages - 'created' => 'The alimtalk template has been registered.', - 'created_requested' => 'The alimtalk template has been registered and inspection requested.', - 'updated' => 'The alimtalk template has been updated.', - 'deleted' => 'The alimtalk template has been deleted.', - 'requested' => 'Inspection has been requested.', - 'request_canceled' => 'The inspection request has been canceled.', - 'stopped' => 'The template has been stopped.', - 'resumed' => 'The template has been resumed.', - 'approval_canceled' => 'The approval has been canceled.', - 'released' => 'The template has been released from dormancy.', - - // Status badges (serviceStatus) - 'status' => [ - 'sendable' => 'Sendable', - 'inspecting' => 'Inspecting', - 'rejected' => 'Rejected', - 'uninspected' => 'Not inspected', - 'stopped' => 'Stopped', - 'blocked' => 'Blocked', - 'dormant' => 'Dormant', - 'unknown' => 'Unknown', - ], - - // Template types (templateEmphasizeType) - 'emphasize_type' => [ - 'none' => 'Basic', - 'text' => 'Highlighted', - 'image' => 'Image', - 'item_list' => 'Item list', - ], - - // Validation attribute labels (FormRequest attributes) - 'fields' => [ - 'name' => 'Template name', - 'content' => 'Template content', - 'category' => 'Category', - 'emphasize_type' => 'Template type', - 'code' => 'Template code', - 'title' => 'Highlight title', - 'subtitle' => 'Highlight subtitle', - 'image' => 'Image', - 'image_name' => 'Image file name', - 'image_url' => 'Image URL', - ], - ], - // Notification-to-alimtalk template binding (NotificationBindingController responses) 'binding' => [ 'saved' => 'Alimtalk binding saved.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php index cf10f0ae..6619eb3b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php @@ -1,34 +1,9 @@ [ - 'sirsoft-message_bizppurio' => [ - 'alimtalk_template' => [ - 'create' => '알림톡 템플릿 등록', - 'update' => '알림톡 템플릿 수정', - 'delete' => '알림톡 템플릿 삭제', - 'request' => '알림톡 템플릿 검수 요청', - 'cancel_request' => '알림톡 템플릿 검수 요청 취소', - 'stop' => '알림톡 템플릿 중지', - 'reuse' => '알림톡 템플릿 중지 해제', - 'cancel_approval' => '알림톡 템플릿 승인 취소', - 'release' => '알림톡 템플릿 휴면 해제', - ], - ], - ], - - // description 본문 — 표준 패턴 activity_log.description.X (DescriptionResolver 해석 대상). - 'description' => [ - 'alimtalk_template_create' => '알림톡 템플릿 등록 (:template_name)', - 'alimtalk_template_update' => '알림톡 템플릿 수정 (:template_code)', - 'alimtalk_template_delete' => '알림톡 템플릿 삭제 (:template_code)', - 'alimtalk_template_request' => '알림톡 템플릿 검수 요청 (:template_code)', - 'alimtalk_template_cancel_request' => '알림톡 템플릿 검수 요청 취소 (:template_code)', - 'alimtalk_template_stop' => '알림톡 템플릿 중지 (:template_code)', - 'alimtalk_template_reuse' => '알림톡 템플릿 중지 해제 (:template_code)', - 'alimtalk_template_cancel_approval' => '알림톡 템플릿 승인 취소 (:template_code)', - 'alimtalk_template_release' => '알림톡 템플릿 휴면 해제 (:template_code)', - ], + 'action' => [], + 'description' => [], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index bc1bcdf6..2396ce37 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -76,55 +76,6 @@ return [ 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', ], - // 알림톡 템플릿 관리 (Phase 5) - 'template' => [ - // 액션 결과 메시지 - 'created' => '알림톡 템플릿을 등록했습니다.', - 'created_requested' => '알림톡 템플릿을 등록하고 검수를 요청했습니다.', - 'updated' => '알림톡 템플릿을 수정했습니다.', - 'deleted' => '알림톡 템플릿을 삭제했습니다.', - 'requested' => '검수를 요청했습니다.', - 'request_canceled' => '검수 요청을 취소했습니다.', - 'stopped' => '템플릿을 중지했습니다.', - 'resumed' => '템플릿 중지를 해제했습니다.', - 'approval_canceled' => '승인을 취소했습니다.', - 'released' => '휴면을 해제했습니다.', - - // 상태 배지 (serviceStatus) - 'status' => [ - 'sendable' => '발송가능', - 'inspecting' => '검수중', - 'rejected' => '반려', - 'uninspected' => '미검수', - 'stopped' => '중지', - 'blocked' => '차단', - 'dormant' => '휴면', - 'unknown' => '알 수 없음', - ], - - // 템플릿 유형 (templateEmphasizeType) - 'emphasize_type' => [ - 'none' => '기본형', - 'text' => '강조표기형', - 'image' => '이미지형', - 'item_list' => '아이템리스트형', - ], - - // 검증 속성 라벨 (FormRequest attributes) - 'fields' => [ - 'name' => '템플릿명', - 'content' => '템플릿 내용', - 'category' => '카테고리', - 'emphasize_type' => '템플릿 유형', - 'code' => '템플릿 코드', - 'title' => '강조표기 문구', - 'subtitle' => '보조 문구', - 'image' => '이미지', - 'image_name' => '이미지 파일명', - 'image_url' => '이미지 링크', - ], - ], - // 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답) 'binding' => [ 'saved' => '알림톡 연동을 저장했습니다.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json index d873dc8c..1bb467e1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json @@ -28,14 +28,39 @@ } }, { - "comment": "연결됨: 템플릿명(또는 코드) · SMS 대체 표시 / 미연결: '미연결'", + "comment": "연결됨: 템플릿명(또는 코드)", "type": "basic", "name": "Span", "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}", "props": { "className": "text-sm text-gray-700 dark:text-gray-200 truncate" }, - "text": "{{(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code) + (bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ? ' · SMS' : '')}}" + "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}" + }, + { + "comment": "⑧ SMS 대체발송 배지 — 연결됨일 때만, 켜짐/꺼짐을 색으로 구분해 둘 다 표시(다크에서 묽지 않게 solid 색)", + "type": "basic", + "name": "Span", + "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled}}", + "props": { + "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-200 flex-shrink-0" + }, + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_on" } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && !(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}", + "props": { + "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0" + }, + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_off" } + ] }, { "type": "basic", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx index 143fa0b8..fc8c8229 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx @@ -1,12 +1,14 @@ /** - * 비즈뿌리오 메시징 플러그인 알림톡 템플릿 관리 탭 구조 검증 (Phase 5, §6-3·6-4) + * 비즈뿌리오 메시징 플러그인 알림톡 템플릿 조회 탭 구조 검증 (조회 전용) * - * plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 관리 화면을 검증한다. - * - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(activeSettingsTab) - * - 목록 서브뷰(상태필터·검색·상태배지·상세) / 폼 서브뷰(유형별 필드·변수삽입) - * - 상세 모달 / readiness 안내 / 데이터소스 / i18n 정합 + * plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 화면을 검증한다. + * 조회 전용 전환: 등록·수정·삭제·검수·상태변경을 제거하고 목록·상태·내용 조회 + 알림 연결만 + * 남겼다. 등록·관리는 비즈뿌리오 콘솔로 위임한다. + * - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(query.tab) + * - 목록 서브뷰(상태필터·검색·상태배지·[내용] 버튼) — [새 템플릿]·폼·상태변경 없음 + * - 상세 모달([닫기]만, 관리 액션 없음) / readiness 안내 / 준비 안내 / i18n 정합 * - * 독립 페이지·메뉴 없음(⚑ 결정 2·3): 설정 페이지 탭으로만 진입. + * 독립 페이지·메뉴 없음: 설정 페이지 탭으로만 진입. */ import { describe, it, expect } from 'vitest'; @@ -25,11 +27,12 @@ describe('alimtalk templates — 탭 네비게이션', () => { expect(findById(nav, 'tab_templates')).toBeTruthy(); }); - it('알림톡 탭 버튼이 query.tab=templates 로 URL 을 갱신하고 목록을 refetch 한다', () => { + it('알림톡 탭 버튼이 navigate replace 로 query.tab=templates 를 갱신하고 목록을 refetch 한다', () => { const raw = JSON.stringify(findById(root, 'tab_templates')); - expect(raw).toContain('replaceUrl'); + // 탭 전환은 navigate replace(+mergeQuery)로 if 재평가 → 화면 전환 (replaceUrl 은 if 미재평가라 부적합) + expect(raw).toContain('"handler":"navigate"'); + expect(raw).toContain('"replace":true'); expect(raw).toContain('"tab":"templates"'); - expect(raw).toContain('"templateView":"list"'); expect(raw).toContain('alimtalk_templates'); }); @@ -49,22 +52,39 @@ describe('alimtalk templates — 탭 네비게이션', () => { }); describe('alimtalk templates — 데이터소스', () => { - it('템플릿 목록·카테고리 데이터소스가 admin API 를 조회한다', () => { + it('템플릿 목록 데이터소스가 admin API 를 조회한다 (조회 전용 — 카테고리 데이터소스 없음)', () => { const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? []; const list = sources.find((s) => s.id === 'alimtalk_templates'); const cats = sources.find((s) => s.id === 'alimtalk_categories'); expect(list?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates'); expect(list?.auto_fetch).toBe(false); - expect(cats?.endpoint).toContain('/alimtalk-templates/categories'); + // 카테고리 데이터소스는 등록 폼 전용이라 제거됨 + expect(cats).toBeUndefined(); }); }); -describe('alimtalk templates — 목록 서브뷰', () => { - it('목록 서브뷰가 templateView=list 조건으로 표시된다', () => { - const view = findById(root, 'templates_list_view'); - expect((view as { if?: string }).if).toContain("templateView ?? 'list') === 'list'"); +describe('alimtalk templates — 조회 전용 (폼·관리 제거)', () => { + it('폼 서브뷰(templates_form_view)가 제거되었다', () => { + expect(findById(root, 'templates_form_view')).toBeNull(); }); + it('상태변경 확인/실행 모달(alimtalk_template_action_modal)이 제거되었다', () => { + const modals = (root as { modals?: AnyNode[] }).modals ?? []; + expect(modals.find((m) => m.id === 'alimtalk_template_action_modal')).toBeUndefined(); + }); + + it('폼 전환·관리 상태(templateView/templateForm/pendingAction/available_actions)가 레이아웃에 없다', () => { + const raw = JSON.stringify(root); + expect(raw).not.toContain('templateView'); + expect(raw).not.toContain('templateForm'); + expect(raw).not.toContain('pendingAction'); + expect(raw).not.toContain('available_actions'); + // 이미지 업로드 핸들러도 제거 + expect(raw).not.toContain('uploadTemplateImage'); + }); +}); + +describe('alimtalk templates — 목록 서브뷰 (조회 전용)', () => { it('상태 필터 Select 와 검색 입력이 있다', () => { const toolbar = findById(root, 'templates_toolbar'); const raw = JSON.stringify(toolbar); @@ -72,6 +92,14 @@ describe('alimtalk templates — 목록 서브뷰', () => { expect(raw).toContain('templateKeyword'); }); + it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다', () => { + const raw = JSON.stringify(findById(root, 'templates_toolbar')); + expect(raw).toContain('templates.list.refresh'); + // 등록 진입(폼 전환) 없음 + expect(raw).not.toContain('templates.list.new'); + expect(raw).not.toContain('"form"'); + }); + it('빈 목록에서도 헤더 있는 표(카드)를 항상 표시하고 데이터 유무로 Tbody 를 분기한다', () => { const card = findById(root, 'templates_table_card'); expect(card).toBeTruthy(); @@ -93,126 +121,144 @@ describe('alimtalk templates — 목록 서브뷰', () => { expect(raw).toContain('status_badge'); }); - it('[새 템플릿] 버튼이 폼 서브뷰로 전환하고 폼을 초기화한다', () => { - const raw = JSON.stringify(findById(root, 'templates_toolbar')); - expect(raw).toContain('"templateView":"form"'); - expect(raw).toContain('templateEmphasizeType'); + it('관리 컬럼이 [내용] 버튼 하나로 상세 조회 후 상세 모달을 연다 (상태별 액션 없음)', () => { + const raw = JSON.stringify(findById(root, 'templates_table_card')); + // 상세 조회 → 상세 모달 + expect(raw).toContain('templates.actions.detail'); + expect(raw).toContain('"handler":"apiCall"'); + expect(raw).toContain('alimtalk_template_detail_modal'); + // 관리 액션 메뉴(ActionMenu)·switch 분기 없음 + expect(raw).not.toContain('ActionMenu'); + expect(raw).not.toContain('"handler":"switch"'); + expect(raw).not.toContain("id:'edit'"); + expect(raw).not.toContain("id:'delete'"); + }); + + it('목록에 번호·등록요청일·처리일 컬럼이 있다', () => { + const raw = JSON.stringify(findById(root, 'templates_table_card')); + expect(raw).toContain('columns.no'); + expect(raw).toContain('columns.requested_at'); + expect(raw).toContain('columns.processed_at'); + // 순번은 pagination 기준 계산 + index_var + expect(raw).toContain('"index_var":"tplIndex"'); + expect(raw).toContain('pagination?.current_page'); + // 날짜는 kapi 원본 필드 + expect(raw).toContain('tpl.createdAt'); + expect(raw).toContain('tpl.modifiedAt'); + }); + + it('상태 배지가 RDY 일 때만 세부(사용전)를 덧붙인다', () => { + const raw = JSON.stringify(findById(root, 'templates_table_card')); + expect(raw).toContain("tpl.service_status === 'RDY'"); + expect(raw).toContain('status_sub.rdy'); + }); + + it('페이지네이션이 page 상태를 갱신하고 목록을 refetch 한다', () => { + const listView = JSON.stringify(findById(root, 'templates_list_view')); + expect(listView).toContain('"name":"Pagination"'); + expect(listView).toContain('onPageChange'); + expect(listView).toContain('templatePage'); + expect(listView).toContain('pagination?.total_page'); }); }); -describe('alimtalk templates — 폼 서브뷰', () => { - it('폼 서브뷰가 templateView=form 조건으로 표시된다', () => { - const view = findById(root, 'templates_form_view'); - expect((view as { if?: string }).if).toContain("templateView === 'form'"); +describe('alimtalk templates — 콘솔 안내 (조회 전용)', () => { + it('목록 상단 안내가 배지 의미(제목+배지명+설명) + 콘솔 위임 안내를 포함한다', () => { + const raw = JSON.stringify(findById(root, 'templates_list_notice')); + // 배지 의미 — 제목 + 배지명/설명 분리(의미색 라벨) + expect(raw).toContain('status_guide.title'); + expect(raw).toContain('status_guide.sendable_label'); + expect(raw).toContain('status_guide.inspecting_label'); + expect(raw).toContain('status_guide.pending_label'); + // 배지명은 의미색(초록/amber/빨강) solid 로 표의 배지와 매칭 + expect(raw).toContain('text-green-700'); + expect(raw).toContain('text-amber-700'); + expect(raw).toContain('text-red-700'); + // 콘솔 위임 안내 + 콘솔 링크 + expect(raw).toContain('list_notice.console_desc'); + expect(raw).toContain('list_notice.console_link'); + expect(raw).toContain('bizppurio.com'); }); - it('공통 필드(이름·본문)가 수동바인딩(value+onChange)으로 존재한다', () => { - const raw = JSON.stringify(findById(root, 'templates_form_view')); - // 이름: value 바인딩 + templateForm.templateName setState (자동바인딩 name 대신) - expect(raw).toContain('templateForm.templateName'); - expect(raw).toContain('_local.templateForm?.templateName'); - // 본문: templateForm.templateContent 수동바인딩 - expect(raw).toContain('templateForm.templateContent'); - }); - - it('강조표기형(TEXT) 필드가 유형 조건으로 표시된다', () => { - const text = findById(root, 'template_text_fields'); - expect((text as { if?: string }).if).toContain("templateEmphasizeType === 'TEXT'"); - const raw = JSON.stringify(text); - expect(raw).toContain('templateTitle'); - expect(raw).toContain('templateSubtitle'); - }); - - it('이미지형(IMAGE) 필드가 유형 조건으로 표시된다', () => { - const image = findById(root, 'template_image_fields'); - expect((image as { if?: string }).if).toContain("templateEmphasizeType === 'IMAGE'"); - const raw = JSON.stringify(image); - expect(raw).toContain('templateImageName'); - expect(raw).toContain('templateImageUrl'); - }); - - it('유형 선택 버튼이 1차 지원 3종(NONE/IMAGE/TEXT)만 노출하고 ITEM_LIST 는 없다', () => { - const raw = JSON.stringify(findById(root, 'templates_form_view')); - // 버튼 방식: setState 로 templateEmphasizeType 을 직접 설정 - expect(raw).toContain('"templateForm.templateEmphasizeType":"NONE"'); - expect(raw).toContain('"templateForm.templateEmphasizeType":"IMAGE"'); - expect(raw).toContain('"templateForm.templateEmphasizeType":"TEXT"'); - expect(raw).not.toContain('ITEM_LIST'); - }); - - it('변수 버튼이 insertVariable 핸들러로 본문 상태(statePath)에 삽입한다', () => { - const raw = JSON.stringify(findById(root, 'template_variables')); - expect(raw).toContain('sirsoft-message_bizppurio.insertVariable'); - expect(raw).toContain('"target":"templateContent"'); - expect(raw).toContain('"statePath":"templateForm.templateContent"'); - }); - - it('이벤트별 변수 참고표가 토글 없이 항상 표시된다(인라인 배열 iteration)', () => { - const raw = JSON.stringify(findById(root, 'template_variables')); - // 참고표 iteration 이 인라인 배열(varref lang 라벨 + 변수 예시)을 렌더 - expect(raw).toContain('templates.varref.welcome'); - expect(raw).toContain('"item_var":"vref"'); - // showVarRef 토글 조건이 없어야 함(항상 표시) - expect(raw).not.toContain('showVarRef'); - }); - - it('선택 항목이 계획서 §6-4 6개(부가정보·미리보기·버튼·바로연결·대표링크·보안) 체크펼침으로 존재하고 헤더는 없다', () => { - const raw = JSON.stringify(findById(root, 'template_optional')); - expect(raw).toContain('templateForm.templateExtra'); - expect(raw).toContain('templateForm.templatePreviewMessage'); - expect(raw).toContain('templateForm.buttons'); - expect(raw).toContain('templateForm.quickReplies'); - expect(raw).toContain('templateForm.templateRepresentLink'); - expect(raw).toContain('templateForm.securityFlag'); - // 각 항목 체크박스 토글(_local.opt*) - expect(raw).toContain('optExtra'); - expect(raw).toContain('optButton'); - expect(raw).toContain('optLink'); - // 헤더(ITEM_LIST 전용, §6-4 선택항목 아님)는 제거됨 - expect(raw).not.toContain('templateForm.templateHeader'); - }); - - it('템플릿 코드(자동/직접)·카테고리 2단 드롭다운·이미지 파일첨부가 존재한다', () => { - const codeField = JSON.stringify(findById(root, 'template_code_field')); - expect(codeField).toContain('templateCodeMode'); - expect(codeField).toContain('"auto"'); - expect(codeField).toContain('"manual"'); - - const catField = JSON.stringify(findById(root, 'template_category_field')); - expect(catField).toContain('categoryGroup'); - expect(catField).toContain('templateForm.categoryCode'); - expect(catField).toContain('alimtalk_categories'); - - const imgField = JSON.stringify(findById(root, 'template_image_fields')); - expect(imgField).toContain('"type":"file"'); - expect(imgField).toContain('sirsoft-message_bizppurio.uploadTemplateImage'); - expect(imgField).toContain('templateImageUrl'); - }); - - it('저장 버튼이 [등록]과 [등록 후 검수요청] 2개로 분리된다', () => { - const raw = JSON.stringify(findById(root, 'templates_form_view')); - expect(raw).toContain('"method":"POST"'); - expect(raw).toContain('_local.templateForm'); - expect(raw).toContain('"templateView":"list"'); - // 등록 후 검수요청: requestInspection 플래그를 body 에 실음 - expect(raw).toContain('requestInspection'); - expect(raw).toContain('save_register'); - expect(raw).toContain('save_and_request'); + it('환경설정 탭에 사용 전 준비 안내(카카오·SMS 통합 1박스)가 있고 info_panel 은 제거되었다', () => { + const notice = findById(root, 'preparation_notice'); + expect(notice).toBeTruthy(); + const raw = JSON.stringify(notice); + // 통합 박스: 문자(발신번호) + 카카오(채널·템플릿·API키 3단계) + 콘솔 링크 (박스 제목 없이 채널 그룹만) + expect(raw).toContain('preparation.sms_label'); + expect(raw).toContain('preparation.sms_sender'); + expect(raw).toContain('preparation.kakao_label'); + expect(raw).toContain('preparation.kakao_channel'); + expect(raw).toContain('preparation.kakao_template'); + expect(raw).toContain('preparation.kakao_apikey'); + expect(raw).toContain('preparation.console_link'); + // 제목·본문 sm 로 상향(가독성) + expect(raw).toContain('text-sm'); + // 중복이던 연동정보 안내(info_panel)는 제거(운영전환 경고·리포트 안내는 폼/리포트 섹션이 담당) + expect(findById(root, 'info_panel')).toBeNull(); }); }); -describe('alimtalk templates — 상세 모달 & readiness', () => { +describe('alimtalk templates — 상세 모달 & readiness (조회 전용)', () => { it('상세 모달이 정의되어 있다', () => { const modals = (root as { modals?: AnyNode[] }).modals ?? []; const detail = modals.find((m) => m.id === 'alimtalk_template_detail_modal'); expect(detail).toBeTruthy(); }); - it('readiness 안내가 api_key/sender_key 미설정 시 조건부로 표시된다', () => { + it('상세 모달이 상태 배지·내용을 표시하고 [닫기]만 노출한다 (관리 액션 없음)', () => { + const modals = (root as { modals?: AnyNode[] }).modals ?? []; + const detail = JSON.stringify(modals.find((m) => m.id === 'alimtalk_template_detail_modal')); + // 상태 배지 + 내용(카테고리·유형·버튼) 표시 + expect(detail).toContain('status_badge'); + expect(detail).toContain('templates.detail.category'); + expect(detail).toContain('templates.detail.buttons'); + expect(detail).toContain('columns.requested_at'); + // 닫기만 — 관리 액션(available_actions)·상태변경 모달 연결 없음 + expect(detail).toContain('templates.detail.close'); + expect(detail).not.toContain('available_actions'); + expect(detail).not.toContain('alimtalk_template_action_modal'); + }); + + it('readiness 안내가 미준비(ready=false) 시 조건부로 표시되고 항목별 상태를 노출한다', () => { const readiness = findById(root, 'templates_readiness'); + // 최상위 노출 조건은 ready 플래그 기반 const cond = (readiness as { if?: string }).if ?? ''; - expect(cond).toContain('api_key'); - expect(cond).toContain('sender_key'); + expect(cond).toContain('templates_readiness?.data'); + expect(cond).toContain('ready'); + // 자식 노드에서 항목별 미설정(api_key_set/sender_key_set) 조건부 표시 + const raw = JSON.stringify(readiness); + expect(raw).toContain('api_key_set'); + expect(raw).toContain('sender_key_set'); + }); +}); + +describe('alimtalk templates — 회귀', () => { + it('컴포넌트 최상위 actions 는 모두 이벤트 type 을 가진다 (charAt 회귀 방지)', () => { + // 회귀: 컴포넌트의 actions 배열 최상위 항목에 type(click 등) 이 없으면 엔진이 + // getReactEventName 에서 eventType.charAt(0) 을 호출하다 "Cannot read properties of + // undefined (reading 'charAt')" 로 그 컴포넌트 렌더가 통째로 실패한다(PO 브라우저 검수로 발견). + const bad: string[] = []; + const walk = (node: AnyNode, path: string): void => { + if (Array.isArray(node)) { + node.forEach((n, i) => walk(n as AnyNode, `${path}[${i}]`)); + return; + } + if (node && typeof node === 'object') { + const obj = node as Record; + // 컴포넌트의 actions 만 검사 (sequence/switch 내부 하위 actions 는 handler 를 가짐 → 제외) + if (Array.isArray(obj.actions) && obj.handler === undefined) { + (obj.actions as Array>).forEach((a, i) => { + if (a && typeof a === 'object' && a.type === undefined) { + bad.push(`${path}.actions[${i}] handler=${String(a.handler)}`); + } + }); + } + for (const k of Object.keys(obj)) walk(obj[k] as AnyNode, `${path}.${k}`); + } + }; + walk(root, 'root'); + expect(bad, `type 누락 최상위 액션:\n${bad.join('\n')}`).toEqual([]); }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index b233e1bb..f3a28ec8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -57,7 +57,7 @@ describe('plugin_settings.json — 자동바인딩', () => { describe('plugin_settings.json — 섹션', () => { it.each([ - ['info_panel', '상단 안내 패널'], + ['preparation_notice', '사용 전 준비 안내(상단)'], ['section_api', 'API 연동'], ['section_sending', '발송 설정'], ['report_section', '리포트 수신 설정'], @@ -155,10 +155,9 @@ describe('plugin_settings.json — 저장 버튼', () => { it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => { const handlers = collectHandlers(layout); const allowed = [ - 'apiCall', 'setState', 'toast', 'navigate', 'sequence', + 'apiCall', 'setState', 'toast', 'navigate', 'sequence', 'switch', 'refetchDataSource', 'scrollIntoView', 'copyToClipboard', 'openModal', 'closeModal', 'replaceUrl', - 'sirsoft-message_bizppurio.insertVariable', 'sirsoft-message_bizppurio.uploadTemplateImage', ]; for (const h of handlers) { @@ -234,13 +233,15 @@ describe('plugin_settings.json — 목록 조회 실패 표시', () => { expect(onError).toContain('kakao_message'); }); - it('목록 오류 배너가 templateListError 조건으로 존재한다', () => { + it('목록 오류 배너가 오류 존재 + 준비완료(ready) 조건으로 존재한다', () => { const banner = findById(layout, 'templates_list_error') as | Record | undefined; expect(banner).toBeTruthy(); - expect(banner?.if).toBe('{{_local.templateListError}}'); - // 사유 본문을 그대로 렌더한다 + // 키 미설정(ready=false)일 때는 readiness 안내 배너가 담당 → 빨간 오류 배너는 숨겨 + // 두 배너가 동시에 뜨지 않게 한다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우만 노출. + expect(banner?.if).toBe('{{_local.templateListError && templates_readiness?.data?.ready}}'); + // 사유 본문(카카오 실제 사유)을 그대로 렌더한다 expect(JSON.stringify(banner)).toContain('{{_local.templateListError}}'); }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts index 5858cc2b..ffbf6891 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts @@ -2,13 +2,10 @@ * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵. * * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인 - * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable + * 식별자가 네임스페이스로 접두된다. + * + * 현재 등록 핸들러 없음(알림톡 템플릿 등록·이미지 업로드 제거로 uploadTemplateImage 제거). + * 커스텀 핸들러 추가 시 이 맵에 등록한다. */ -import { insertVariableHandler } from './insertVariable'; -import { uploadTemplateImageHandler } from './uploadTemplateImage'; - -export const handlerMap = { - insertVariable: insertVariableHandler, - uploadTemplateImage: uploadTemplateImageHandler, -} as const; +export const handlerMap = {} as const; \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts deleted file mode 100644 index bd661ddf..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * insertVariable 핸들러 - * - * 알림톡 템플릿 등록/수정 화면에서 "사용 가능 변수" 버튼을 클릭하면, 대상 본문의 현재 - * 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.`)를 SSoT - * 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌 - * (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다. - * - * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다. - * - * 레이아웃 JSON 사용 예: - * { - * "handler": "sirsoft-message_bizppurio.insertVariable", - * "params": { "variable": "name", "target": "templateContent", "statePath": "templateForm.templateContent" } - * } - */ - -import type { ActionContext, ActionWithParams } from '../types'; - -const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? { - log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args), - warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args), - error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args), -}; - -/** 삽입 대상 본문 필드(name) 기본값 */ -const DEFAULT_TARGET = 'templateContent'; - -/** 상태 경로 기본값 (_local 하위) */ -const DEFAULT_STATE_PATH = 'templateForm.templateContent'; - -/** - * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다. - * - * @param action 액션 객체(params.variable, params.target, params.statePath) - * @param context 액션 컨텍스트(state 스냅샷) - */ -export function insertVariableHandler( - action: ActionWithParams, - context: ActionContext, -): void { - const G7Core = (window as any).G7Core; - const params = action.params ?? {}; - const variable = String(params.variable ?? '').trim(); - const target = String(params.target ?? DEFAULT_TARGET); - const statePath = String(params.statePath ?? DEFAULT_STATE_PATH); - - if (variable === '') { - logger.warn('삽입할 변수명이 지정되지 않았습니다.'); - return; - } - - if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) { - logger.error('G7Core.state API 를 사용할 수 없습니다.'); - return; - } - - // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal. - const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {}; - const current = String(readPath(localState, statePath) ?? ''); - - const token = `#{${variable}}`; - - // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립. - const field = document.querySelector( - `textarea[name="${target}"], input[name="${target}"]`, - ); - const domValue = field?.value ?? ''; - // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다. - const inSync = domValue === current; - const start = inSync ? (field?.selectionStart ?? current.length) : current.length; - const end = inSync ? (field?.selectionEnd ?? current.length) : current.length; - - const next = current.slice(0, start) + token + current.slice(end); - - G7Core.state.setLocal({ [statePath]: next }); - - // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서). - if (field) { - const caret = start + token.length; - requestAnimationFrame(() => { - try { - field.focus(); - field.setSelectionRange(caret, caret); - } catch { - // setSelectionRange 미지원 input — 무시 - } - }); - } - - logger.log(`변수 삽입: ${token} → ${statePath}`); -} - -/** - * dot notation 경로로 객체에서 값을 읽습니다. (예: "templateForm.templateContent") - * - * @param obj 대상 객체 - * @param path dot 경로 - * @return 경로 값(없으면 undefined) - */ -function readPath(obj: Record, path: string): unknown { - return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts deleted file mode 100644 index e7e626ae..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * uploadTemplateImage 핸들러 - * - * 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의 - * 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을 - * `_local.templateForm.templateImageUrl` 에, 파일명을 `templateImageName` 에 설정한다. - * - * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다. - * 진행 상태는 `_local.imageUploading`, 오류는 `_local.imageUploadError` 로 노출한다. - * - * 레이아웃 JSON 사용 예: - * { - * "type": "change", - * "handler": "sirsoft-message_bizppurio.uploadTemplateImage" - * } - * (대상 file input 의 change 이벤트에 바인딩; $event.target.files[0] 를 사용) - */ - -import type { ActionContext, ActionWithParams } from '../types'; - -const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? { - log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args), - warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args), - error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args), -}; - -const UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image'; - -/** - * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다. - * - * @param action 액션 객체(미사용 params) - * @param context 액션 컨텍스트($event 로 파일 접근) - */ -export async function uploadTemplateImageHandler( - action: ActionWithParams, - context: ActionContext, -): Promise { - const G7Core = (window as any).G7Core; - if (!G7Core?.state?.setLocal) { - logger.error('G7Core.state.setLocal 을 사용할 수 없습니다.'); - return; - } - - const event = (context?.event ?? (action as any)?.event) as Event | undefined; - const input = event?.target as HTMLInputElement | undefined; - const file = input?.files?.[0]; - - if (!file) { - return; - } - - G7Core.state.setLocal({ imageUploading: true, imageUploadError: null }); - - try { - const token = localStorage.getItem('auth_token') ?? ''; - const form = new FormData(); - form.append('image', file, file.name); - - const res = await fetch(UPLOAD_URL, { - method: 'POST', - headers: { - Accept: 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: form, - }); - - const json = await res.json().catch(() => ({})); - - if (!res.ok || json?.success === false) { - const message = json?.errors?.kakao_message || json?.message || '이미지 업로드에 실패했습니다.'; - G7Core.state.setLocal({ imageUploading: false, imageUploadError: message }); - logger.warn('이미지 업로드 실패:', message); - return; - } - - const url = json?.data?.image_url ?? ''; - const name = json?.data?.image_name ?? file.name; - - G7Core.state.setLocal({ - imageUploading: false, - imageUploadError: null, - 'templateForm.templateImageUrl': url, - 'templateForm.templateImageName': name, - }); - - logger.log('이미지 업로드 완료:', url); - } catch (e) { - G7Core.state.setLocal({ imageUploading: false, imageUploadError: String(e) }); - logger.error('이미지 업로드 예외:', e); - } finally { - // 같은 파일 재선택이 가능하도록 input 값 초기화 - if (input) { - input.value = ''; - } - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts index ba8d309e..9bff1ba5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts @@ -1,8 +1,9 @@ /** * sirsoft-message_bizppurio 플러그인 엔트리포인트. * - * 플러그인 활성화 시 자동 로드되어 커스텀 핸들러(insertVariable)를 ActionDispatcher 에 + * 플러그인 활성화 시 자동 로드되어 handlerMap 의 커스텀 핸들러를 ActionDispatcher 에 * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다. + * (현재 등록 핸들러 없음 — 필요 시 handlers/index.ts 에 추가.) */ import { handlerMap } from './handlers'; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index 414c1c6e..23f7a401 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -1,241 +1,199 @@ { - "name": "Bizppurio Messaging", - "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin.", - "settings": { - "title": "Bizppurio Messaging Settings", - "description": "Manage Bizppurio integration credentials and sending options.", - "save": "Save", - "saving": "Saving...", - "save_success": "Settings saved.", - "save_failed": "Failed to save settings.", - "info": { - "credentials": "Enter the integration credentials (ID, password, API key) used for sending and Kakao management, along with the sender information. Verify every value before turning off test mode and switching to production.", - "report": "To automatically receive delivery results (success/failure) and record them in the history, register the report endpoint below with Bizppurio." - }, - "test_mode": { - "label": "Test Mode", - "hint": "In test mode, messages are sent to the Bizppurio inspection (dev) domain. Turn it off to send through the production domain." - }, - "live_mode_warning_title": "Sending in production", - "live_mode_warning_body": "Test mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep test mode on while inspecting.", - "sections": { - "api": { - "title": "API Integration", - "description": "Credentials shared by the sending system and the Kakao management system." - }, - "sending": { - "title": "Sending Options", - "description": "Sender information used for SMS and alimtalk delivery." - } - }, - "fields": { - "bizppurio_id": { - "label": "Bizppurio ID", - "hint": "The Bizppurio account ID used for both sending and Kakao management." - }, - "password": { - "label": "Password", - "hint": "The Bizppurio password used to issue the sending token." - }, - "api_key": { - "label": "API Key", - "hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support." - }, - "sender_number": { - "label": "Sender Number", - "hint": "The sender phone number used for SMS and alimtalk delivery." - }, - "sender_key": { - "label": "Alimtalk Sender Profile Key", - "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." - } - }, - "report": { - "section_title": "Report Endpoint", - "hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).", - "note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.", - "copy": "Copy", - "copied": "Report endpoint copied to clipboard." - }, - "tabs": { - "connection": "Settings", - "templates": "Alimtalk Templates" - } + "name": "Bizppurio Messaging", + "description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin.", + "settings": { + "title": "Bizppurio Messaging Settings", + "description": "Manage Bizppurio integration credentials and sending options.", + "save": "Save", + "saving": "Saving...", + "save_success": "Settings saved.", + "save_failed": "Failed to save settings.", + "test_mode": { + "label": "Test Mode", + "hint": "In test mode, messages are sent to the Bizppurio inspection (dev) domain. Turn it off to send through the production domain." }, - "binding": { - "section_title": "KakaoTalk Alimtalk Binding", - "list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.", - "section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.", - "modal_title": "Alimtalk Binding · {name}", - "unbound": "Not connected", - "btn_connect": "Connect", - "btn_change": "Change", - "connected_template": "Connected Template", - "none": "Not connected", - "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", - "fallback_sms": "Fall back to SMS on failure", - "fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.", - "variables_hint": "Available variables (auto-substituted on send)", - "saved": "Alimtalk binding saved.", - "save_error": "Failed to save alimtalk binding." + "live_mode_warning_title": "Sending in production", + "live_mode_warning_body": "Test mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep test mode on while inspecting.", + "sections": { + "api": { + "title": "API Integration", + "description": "Credentials shared by the sending system and the Kakao management system." + }, + "sending": { + "title": "Sending Options", + "description": "Sender information used for SMS and alimtalk delivery." + } }, - "banner": { - "not_ready": "Setup required for delivery is incomplete.", - "setup_action": "Set up", - "test_mode": "Test mode — messages are not actually sent." + "fields": { + "bizppurio_id": { + "label": "Bizppurio ID", + "hint": "The Bizppurio account ID used for both sending and Kakao management." + }, + "password": { + "label": "Password", + "hint": "The Bizppurio password used to issue the sending token." + }, + "api_key": { + "label": "API Key", + "hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support." + }, + "sender_number": { + "label": "Sender Number", + "hint": "The sender phone number used for SMS and alimtalk delivery." + }, + "sender_key": { + "label": "Alimtalk Sender Profile Key", + "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." + } }, - "dispatch_result": { - "column_header": "SMS/Alimtalk Result", - "low_balance": "Low balance", - "fallback": "SMS fallback {status}", - "detail_title": "SMS/Alimtalk delivery result", - "channel_label": "Channel: {channel}" + "report": { + "section_title": "Report Endpoint", + "hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).", + "note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.", + "copy": "Copy", + "copied": "Report endpoint copied to clipboard." }, - "editor": { - "data_source": { - "dispatch_results": "Bizppurio dispatch results" - } + "tabs": { + "connection": "Settings", + "templates": "Alimtalk Templates" }, - "templates": { - "title": "Alimtalk Templates", - "description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.", - "readiness": { - "title": "The following settings are required to use alimtalk templates", - "go_settings": "Go to settings", - "missing_label": "Missing", - "api_key_missing": "Kakao management API key", - "sender_key_missing": "Alimtalk sender profile key", - "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao." - }, - "list_error": { - "title": "Failed to load the template list" - }, - "list": { - "new": "New Template", - "refresh": "Refresh", - "search_placeholder": "Search by name (2-50 chars)", - "filter_all": "All statuses", - "empty": "No alimtalk templates registered.", - "empty_hint": "Register an alimtalk template with the [New Template] button.", - "load_failed": "Failed to load templates. Check the sender profile key and API key.", - "columns": { - "name": "Name", - "code": "Code", - "status": "Status", - "actions": "Actions" - } - }, - "status": { - "sendable": "Sendable", - "inspecting": "Inspecting", - "rejected": "Rejected", - "uninspected": "Not inspected", - "stopped": "Stopped", - "blocked": "Blocked", - "dormant": "Dormant", - "unknown": "Unknown" - }, - "actions": { - "edit": "Edit", - "delete": "Delete", - "request": "Request", - "cancel_request": "Cancel request", - "stop": "Stop", - "reuse": "Resume", - "cancel_approval": "Cancel approval", - "release": "Release", - "detail": "Detail" - }, - "form": { - "title_new": "Register Alimtalk Template", - "title_edit": "Edit Alimtalk Template", - "back_to_list": "Back to list", - "sender_key": "Sender profile key", - "code": "Template code", - "code_auto": "Auto-generate", - "code_manual": "Enter manually", - "code_hint": "Letters, digits, underscore(_), hyphen(-), max 30 chars", - "name": "Template name", - "name_hint": "Management name (not part of content), max 200 chars", - "category": "Category", - "category_group": "Group", - "category_item": "Item", - "emphasize_type": "Template type", - "type_none": "Basic", - "type_image": "Image", - "type_text": "Highlighted", - "type_item_list_notice": "Advanced templates such as item-list should be registered on the Bizppurio website. Templates registered there are also available for listing and bindings.", - "goto_bizppurio": "Bizppurio website", - "image_name": "Image file name", - "image_url": "Image URL", - "image_upload": "Attach image", - "image_upload_hint": "jpg/png / max 500KB / at least 500px wide (2:1 to 3:4 ratio)", - "image_uploading": "Uploading image...", - "image_remove": "Remove image", - "image_maker_notice": "The image maker (auto-generation tool) is provided in the Bizppurio console.", - "title_field": "Highlight title", - "title_hint": "Only content present in the body, max 23 chars (variables allowed)", - "subtitle_field": "Highlight subtitle", - "subtitle_hint": "Only content present in the body, max 18 chars (no variables)", - "content": "Template content", - "content_placeholder": "Enter the body. Insert variables with #{variable}.", - "content_hint": "Max 1300 chars", - "variables_title": "Insert variable", - "variables_help": "Insert #{variable} into the body; when you bind this template to a notification, it is replaced with the real value (member name, order number, etc.). Below are example variables per notification — insert the ones matching the notification you will bind.", - "variables_quick": "Common variables (click to insert at cursor)", - "variables_ref_toggle": "Variable reference by notification", - "variables_ref_event": "Notification", - "variables_ref_vars": "Provided variables", - "optional_title": "Optional fields", - "optional_toggle": "Expand optional fields (extra info, buttons, etc.)", - "optional_help": "Check only the items you need to reveal their inputs. Buttons and quick replies assume a single web link (WL).", - "button": "Button", - "button_name": "Button label", - "button_url": "Mobile link URL", - "button_hint": "A web-link button shown at the bottom of the message.", - "quick_reply": "Quick reply", - "quick_reply_hint": "Quick reply (web link) used on consult-talk channels.", - "represent_link": "Represent link", - "link_pc": "PC web link", - "link_mo": "Mobile web link", - "preview_message": "Preview message", - "preview_message_hint": "Summary shown in the chat list (max 40 chars).", - "extra": "Extra info", - "extra_hint": "Supplementary text outside the body such as channel-add or ad notices (registers the message type as extra-info type).", - "security_flag": "Secure template (OTP, etc.)", - "security_flag_hint": "Turn on for secure messages such as OTP. The message text is hidden on devices other than the sending device.", - "preview_title": "Preview", - "preview_empty": "Enter the body to see a preview here.", - "save_register": "Register", - "save_register_hint": "Registers as a draft only. To send, request inspection from the list and get Kakao approval.", - "save_and_request": "Register and request inspection", - "save_and_request_hint": "Registers and requests Kakao inspection at once. You cannot edit after requesting." - }, - "detail": { - "title": "Template detail", - "close": "Close" - }, - "confirm": { - "delete": "Delete this template?", - "request": "Request inspection? You cannot edit after requesting.", - "cancel_request": "Cancel the inspection request?", - "stop": "Stop this template? It cannot be used for delivery once stopped.", - "cancel_approval": "Cancel the approval? Re-inspection will be required." - }, - "toast": { - "created": "The alimtalk template has been registered.", - "created_requested": "The alimtalk template has been registered and inspection requested.", - "updated": "The alimtalk template has been updated.", - "deleted": "The alimtalk template has been deleted.", - "action_success": "The request has been processed.", - "action_failed": "Failed to process the request." - }, - "varref": { - "welcome": "Welcome (signup)", - "reset_password": "Password reset", - "order_confirmed": "Order confirmed", - "order_shipped": "Order shipped", - "new_comment": "New comment" - } + "preparation": { + "sms_label": "SMS/LMS", + "sms_sender": "Register your sender number.", + "kakao_label": "Kakao Alimtalk", + "kakao_channel": "Create a KakaoTalk business channel and register a sender profile.", + "kakao_template": "Register your Alimtalk templates and get them approved.", + "kakao_apikey": "Request an API key from Bizppurio support.", + "console_link": "Open Bizppurio console" } + }, + "binding": { + "section_title": "KakaoTalk Alimtalk Binding", + "list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.", + "section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.", + "modal_title": "Alimtalk Binding · {name}", + "unbound": "Not connected", + "btn_connect": "Connect", + "btn_change": "Change", + "fallback_on": "SMS fallback ON", + "fallback_off": "SMS fallback OFF", + "connected_template": "Connected Template", + "none": "Not connected", + "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", + "fallback_sms": "Fall back to SMS on failure", + "fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.", + "variables_hint": "Available variables (auto-substituted on send)", + "saved": "Alimtalk binding saved.", + "save_error": "Failed to save alimtalk binding." + }, + "banner": { + "not_ready": "Setup required for delivery is incomplete.", + "setup_action": "Set up", + "test_mode": "Test mode — messages are not actually sent." + }, + "dispatch_result": { + "column_header": "SMS/Alimtalk Result", + "low_balance": "Low balance", + "fallback": "SMS fallback {status}", + "detail_title": "SMS/Alimtalk delivery result", + "channel_label": "Channel: {channel}" + }, + "editor": { + "data_source": { + "dispatch_results": "Bizppurio dispatch results" + } + }, + "templates": { + "title": "Alimtalk Templates", + "description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.", + "readiness": { + "title": "The following settings are required to use alimtalk templates", + "go_settings": "Go to settings", + "missing_label": "Missing", + "api_key_missing": "Kakao management API key", + "sender_key_missing": "Alimtalk sender profile key", + "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao." + }, + "list_error": { + "title": "Failed to load the template list" + }, + "list_notice": { + "console_desc": "Register, edit, and review templates in the Bizppurio console.", + "console_link": "Open Bizppurio console" + }, + "list": { + "refresh": "Refresh", + "search": "Search", + "search_placeholder": "Search by name (2-50 chars)", + "filter_all": "All statuses", + "empty": "No alimtalk templates registered.", + "empty_hint": "Templates registered in the Bizppurio console appear here.", + "load_failed": "Failed to load templates. Check the sender profile key and API key.", + "columns": { + "no": "No.", + "name": "Name", + "code": "Code", + "status": "Status", + "requested_at": "Requested", + "processed_at": "Processed", + "actions": "Content" + } + }, + "status": { + "sendable": "Sendable", + "inspecting": "Inspecting", + "rejected": "Rejected", + "uninspected": "Not inspected", + "stopped": "Stopped", + "blocked": "Blocked", + "dormant": "Dormant", + "unknown": "Unknown" + }, + "status_sub": { + "rdy": "(unused)" + }, + "status_guide": { + "title": "Status badges", + "sendable_label": "Sendable", + "sendable": "Approved. Only this status can be linked to notifications and sent.", + "inspecting_label": "Inspecting", + "inspecting": "Kakao review in progress (2-3 business days).", + "pending_label": "Uninspected / Rejected", + "pending": "Cannot be sent yet. Request review or edit in the console." + }, + "link_type": { + "WL": "Web link", + "AL": "App link", + "DS": "Delivery tracking", + "BK": "Bot keyword", + "MD": "Message delivery", + "AC": "Add channel", + "BC": "Consultation talk", + "BT": "Bot transfer", + "TN": "Call", + "MP": "Map", + "P1": "Secure image send", + "P2": "Privacy consent", + "P3": "One-click pay" + }, + "actions": { + "detail": "Detail" + }, + "detail": { + "title": "Template detail", + "close": "Close", + "buttons": "Buttons", + "extra": "Additional info", + "category": "Category", + "code": "Template code", + "content": "Template content", + "emphasize_type": "Template type", + "image_upload": "Attach image", + "subtitle_field": "Highlight subtitle", + "title_field": "Highlight title", + "type_image": "Image", + "type_none": "Basic", + "type_text": "Highlighted" + } + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 4ddcb2d5..d177a645 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -1,241 +1,199 @@ { - "name": "비즈뿌리오 메시지 발송", - "description": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다.", - "settings": { - "title": "비즈뿌리오 메시지 발송 설정", - "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.", - "save": "저장", - "saving": "저장 중...", - "save_success": "설정이 저장되었습니다.", - "save_failed": "설정 저장에 실패했습니다.", - "info": { - "credentials": "발송·카카오 관리에 사용하는 연동 정보(아이디·비밀번호·API 키)와 발신 정보를 입력합니다. 검수 모드를 끄고 운영으로 전환하기 전에 모든 값이 정확한지 확인하세요.", - "report": "발송 결과(성공/실패)를 자동으로 받아 이력에 기록하려면 아래 리포트 수신 주소를 비즈뿌리오에 등록해야 합니다." - }, - "test_mode": { - "label": "검수 모드", - "hint": "검수 모드에서는 비즈뿌리오 검수(개발) 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다." - }, - "live_mode_warning_title": "운영 환경으로 발송됩니다", - "live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.", - "sections": { - "api": { - "title": "API 연동", - "description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다." - }, - "sending": { - "title": "발송 설정", - "description": "문자·알림톡 발송에 사용하는 발신 정보입니다." - } - }, - "fields": { - "bizppurio_id": { - "label": "비즈뿌리오 아이디", - "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." - }, - "password": { - "label": "비밀번호", - "hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다." - }, - "api_key": { - "label": "API 키", - "hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다." - }, - "sender_number": { - "label": "발신번호", - "hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다." - }, - "sender_key": { - "label": "알림톡 발신프로필 키", - "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." - } - }, - "report": { - "section_title": "리포트 수신 설정", - "hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.", - "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.", - "copy": "복사", - "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." - }, - "tabs": { - "connection": "환경설정", - "templates": "알림톡 템플릿" - } + "name": "비즈뿌리오 메시지 발송", + "description": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다.", + "settings": { + "title": "비즈뿌리오 메시지 발송 설정", + "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.", + "save": "저장", + "saving": "저장 중...", + "save_success": "설정이 저장되었습니다.", + "save_failed": "설정 저장에 실패했습니다.", + "test_mode": { + "label": "검수 모드", + "hint": "검수 모드에서는 비즈뿌리오 검수(개발) 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다." }, - "binding": { - "section_title": "카카오 알림톡 연동", - "list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.", - "section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.", - "modal_title": "알림톡 연결 · {name}", - "unbound": "미연결", - "btn_connect": "연결", - "btn_change": "연결 변경", - "connected_template": "연결 템플릿", - "none": "연결 안 함", - "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", - "fallback_sms": "실패 시 SMS로 대체발송", - "fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.", - "variables_hint": "제공 변수 (발송 시 자동 치환)", - "saved": "알림톡 연동을 저장했습니다.", - "save_error": "알림톡 연동 저장에 실패했습니다." + "live_mode_warning_title": "운영 환경으로 발송됩니다", + "live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.", + "sections": { + "api": { + "title": "API 연동", + "description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다." + }, + "sending": { + "title": "발송 설정", + "description": "문자·알림톡 발송에 사용하는 발신 정보입니다." + } }, - "banner": { - "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", - "setup_action": "설정하기", - "test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다." + "fields": { + "bizppurio_id": { + "label": "비즈뿌리오 아이디", + "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다." + }, + "password": { + "label": "비밀번호", + "hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다." + }, + "api_key": { + "label": "API 키", + "hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다." + }, + "sender_number": { + "label": "발신번호", + "hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다." + }, + "sender_key": { + "label": "알림톡 발신프로필 키", + "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." + } }, - "dispatch_result": { - "column_header": "문자·알림톡 결과", - "low_balance": "잔액 부족", - "fallback": "SMS 대체발송 {status}", - "detail_title": "문자·알림톡 발송 결과", - "channel_label": "발송 채널: {channel}" + "report": { + "section_title": "리포트 수신 설정", + "hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.", + "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.", + "copy": "복사", + "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." }, - "editor": { - "data_source": { - "dispatch_results": "비즈뿌리오 발송 결과" - } + "tabs": { + "connection": "환경설정", + "templates": "알림톡 템플릿" }, - "templates": { - "title": "알림톡 템플릿", - "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", - "readiness": { - "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다", - "go_settings": "환경설정으로 이동", - "missing_label": "미설정 항목", - "api_key_missing": "카카오 관리 API 키", - "sender_key_missing": "알림톡 발신프로필 키", - "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다." - }, - "list_error": { - "title": "템플릿 목록을 불러오지 못했습니다" - }, - "list": { - "new": "새 템플릿", - "refresh": "새로고침", - "search_placeholder": "템플릿명 검색 (2~50자)", - "filter_all": "전체 상태", - "empty": "등록된 알림톡 템플릿이 없습니다.", - "empty_hint": "[새 템플릿] 버튼으로 알림톡 템플릿을 등록하세요.", - "load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.", - "columns": { - "name": "템플릿명", - "code": "코드", - "status": "상태", - "actions": "관리" - } - }, - "status": { - "sendable": "발송가능", - "inspecting": "검수중", - "rejected": "반려", - "uninspected": "미검수", - "stopped": "중지", - "blocked": "차단", - "dormant": "휴면", - "unknown": "알 수 없음" - }, - "actions": { - "edit": "수정", - "delete": "삭제", - "request": "검수요청", - "cancel_request": "검수취소", - "stop": "중지", - "reuse": "중지해제", - "cancel_approval": "승인취소", - "release": "휴면해제", - "detail": "상세" - }, - "form": { - "title_new": "알림톡 템플릿 등록", - "title_edit": "알림톡 템플릿 수정", - "back_to_list": "목록으로", - "sender_key": "발신프로필 키", - "code": "템플릿 코드", - "code_auto": "자동 생성", - "code_manual": "직접 입력", - "code_hint": "영문·숫자·언더바(_)·하이픈(-), 최대 30자", - "name": "템플릿명", - "name_hint": "관리용 이름 (내용에 포함되지 않음), 최대 200자", - "category": "카테고리", - "category_group": "대분류", - "category_item": "소분류", - "emphasize_type": "템플릿 유형", - "type_none": "기본형", - "type_image": "이미지형", - "type_text": "강조표기형", - "type_item_list_notice": "아이템리스트형 등 고급 템플릿은 비즈뿌리오 홈페이지에서 등록하세요. 홈페이지에서 등록한 템플릿도 목록 조회·연동에서 그대로 사용할 수 있습니다.", - "goto_bizppurio": "비즈뿌리오 홈페이지", - "image_name": "이미지 파일명", - "image_url": "이미지 링크", - "image_upload": "이미지 첨부", - "image_upload_hint": "jpg·png / 최대 500KB / 가로 500px 이상 (가로:세로 2:1~3:4)", - "image_uploading": "이미지 업로드 중...", - "image_remove": "이미지 제거", - "image_maker_notice": "이미지 메이커(자동 생성 도구)는 비즈뿌리오 콘솔에서 제공됩니다.", - "title_field": "강조표기 문구", - "title_hint": "본문에 있는 내용만, 최대 23자 (변수 사용 가능)", - "subtitle_field": "보조 문구", - "subtitle_hint": "본문에 있는 내용만, 최대 18자 (변수 사용 불가)", - "content": "템플릿 내용", - "content_placeholder": "본문을 입력하세요. #{변수} 형식으로 변수를 넣을 수 있습니다.", - "content_hint": "최대 1300자", - "variables_title": "변수 넣기", - "variables_help": "본문에 #{변수} 형식으로 넣으면, 이 템플릿을 알림에 연결할 때 실제 값(회원 이름·주문번호 등)으로 자동 치환됩니다. 아래는 알림별 제공 변수 예시입니다 — 연결할 알림에 맞는 변수를 본문에 넣으세요.", - "variables_quick": "자주 쓰는 변수 (클릭하면 커서 위치에 삽입)", - "variables_ref_toggle": "알림별 제공 변수 참고표", - "variables_ref_event": "알림", - "variables_ref_vars": "제공 변수", - "optional_title": "선택 항목", - "optional_toggle": "선택 항목 펼치기 (부가정보·버튼 등)", - "optional_help": "필요한 항목만 체크하면 입력란이 나타납니다. 버튼·바로연결은 웹링크(WL) 1개 기준입니다.", - "button": "버튼", - "button_name": "버튼명", - "button_url": "모바일 링크 URL", - "button_hint": "메시지 하단에 표시되는 웹링크 버튼입니다.", - "quick_reply": "바로연결", - "quick_reply_hint": "상담톡 채널에서 사용하는 바로연결(웹링크)입니다.", - "represent_link": "대표 링크", - "link_pc": "PC 웹 링크", - "link_mo": "모바일 웹 링크", - "preview_message": "미리보기 메시지", - "preview_message_hint": "채팅 목록에 표시되는 요약 문구 (최대 40자)", - "extra": "부가정보", - "extra_hint": "채널 추가·광고성 안내 등 본문 외 부가 문구 (입력 시 메시지 유형이 부가정보형으로 등록됩니다)", - "security_flag": "템플릿 보안 (OTP 등)", - "security_flag_hint": "OTP 등 보안 메시지일 때 켜세요. 발신 디바이스 외 다른 기기에는 메시지 텍스트가 표시되지 않습니다.", - "preview_title": "미리보기", - "preview_empty": "본문을 입력하면 여기에 미리보기가 표시됩니다.", - "save_register": "등록", - "save_register_hint": "임시 등록만 합니다. 발송하려면 이후 목록에서 검수요청 → 카카오 승인이 필요합니다.", - "save_and_request": "등록 후 검수요청", - "save_and_request_hint": "등록과 동시에 카카오에 검수를 요청합니다. 요청 후에는 수정할 수 없습니다." - }, - "detail": { - "title": "템플릿 상세", - "close": "닫기" - }, - "confirm": { - "delete": "이 템플릿을 삭제하시겠습니까?", - "request": "검수를 요청하시겠습니까? 요청 후에는 수정할 수 없습니다.", - "cancel_request": "검수 요청을 취소하시겠습니까?", - "stop": "이 템플릿을 중지하시겠습니까? 중지하면 발송에 사용할 수 없습니다.", - "cancel_approval": "승인을 취소하시겠습니까? 취소하면 재검수가 필요합니다." - }, - "toast": { - "created": "알림톡 템플릿을 등록했습니다.", - "created_requested": "알림톡 템플릿을 등록하고 검수를 요청했습니다.", - "updated": "알림톡 템플릿을 수정했습니다.", - "deleted": "알림톡 템플릿을 삭제했습니다.", - "action_success": "요청을 처리했습니다.", - "action_failed": "요청 처리에 실패했습니다." - }, - "varref": { - "welcome": "회원가입 환영", - "reset_password": "비밀번호 재설정", - "order_confirmed": "결제 완료", - "order_shipped": "배송 시작", - "new_comment": "새 댓글" - } + "preparation": { + "sms_label": "문자(SMS/LMS)", + "sms_sender": "발신번호를 등록하세요.", + "kakao_label": "카카오 알림톡", + "kakao_channel": "카카오톡 비즈니스 채널을 만들고 발신프로필을 등록하세요.", + "kakao_template": "알림톡 템플릿을 등록하고 승인받으세요.", + "kakao_apikey": "고객센터에 API 키를 요청하세요.", + "console_link": "비즈뿌리오 콘솔 열기" } + }, + "binding": { + "section_title": "카카오 알림톡 연동", + "list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.", + "section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.", + "modal_title": "알림톡 연결 · {name}", + "unbound": "미연결", + "btn_connect": "연결", + "btn_change": "연결 변경", + "fallback_on": "SMS 대체 ON", + "fallback_off": "SMS 대체 OFF", + "connected_template": "연결 템플릿", + "none": "연결 안 함", + "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", + "fallback_sms": "실패 시 SMS로 대체발송", + "fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.", + "variables_hint": "제공 변수 (발송 시 자동 치환)", + "saved": "알림톡 연동을 저장했습니다.", + "save_error": "알림톡 연동 저장에 실패했습니다." + }, + "banner": { + "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", + "setup_action": "설정하기", + "test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다." + }, + "dispatch_result": { + "column_header": "문자·알림톡 결과", + "low_balance": "잔액 부족", + "fallback": "SMS 대체발송 {status}", + "detail_title": "문자·알림톡 발송 결과", + "channel_label": "발송 채널: {channel}" + }, + "editor": { + "data_source": { + "dispatch_results": "비즈뿌리오 발송 결과" + } + }, + "templates": { + "title": "알림톡 템플릿", + "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", + "readiness": { + "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다", + "go_settings": "환경설정으로 이동", + "missing_label": "미설정 항목", + "api_key_missing": "카카오 관리 API 키", + "sender_key_missing": "알림톡 발신프로필 키", + "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다." + }, + "list_error": { + "title": "템플릿 목록을 불러오지 못했습니다" + }, + "list_notice": { + "console_desc": "템플릿 등록·수정·검수는 비즈뿌리오 콘솔에서 진행해주세요.", + "console_link": "비즈뿌리오 콘솔 열기" + }, + "list": { + "refresh": "새로고침", + "search": "검색", + "search_placeholder": "템플릿명 검색 (2~50자)", + "filter_all": "전체 상태", + "empty": "등록된 알림톡 템플릿이 없습니다.", + "empty_hint": "비즈뿌리오 콘솔에서 등록한 템플릿이 여기에 표시됩니다.", + "load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.", + "columns": { + "no": "번호", + "name": "템플릿명", + "code": "코드", + "status": "상태", + "requested_at": "등록요청일", + "processed_at": "처리일", + "actions": "내용" + } + }, + "status": { + "sendable": "발송가능", + "inspecting": "검수중", + "rejected": "반려", + "uninspected": "미검수", + "stopped": "중지", + "blocked": "차단", + "dormant": "휴면", + "unknown": "알 수 없음" + }, + "status_sub": { + "rdy": "(사용전)" + }, + "status_guide": { + "title": "상태 배지", + "sendable_label": "발송가능", + "sendable": "승인 완료. 이 상태만 알림에 연결·발송할 수 있습니다.", + "inspecting_label": "검수중", + "inspecting": "카카오 검수 진행 중 (영업일 2~3일).", + "pending_label": "미검수·반려", + "pending": "아직 발송할 수 없습니다. 콘솔에서 검수요청·수정하세요." + }, + "link_type": { + "WL": "웹링크", + "AL": "앱링크", + "DS": "배송조회", + "BK": "봇키워드", + "MD": "메시지전달", + "AC": "채널추가", + "BC": "상담톡전환", + "BT": "봇전환", + "TN": "전화하기", + "MP": "지도보기", + "P1": "이미지 보안전송", + "P2": "개인정보이용", + "P3": "원클릭 결제" + }, + "actions": { + "detail": "상세" + }, + "detail": { + "title": "템플릿 상세", + "close": "닫기", + "buttons": "버튼", + "extra": "부가정보", + "category": "카테고리", + "code": "템플릿 코드", + "content": "템플릿 내용", + "emphasize_type": "템플릿 유형", + "image_upload": "이미지 첨부", + "subtitle_field": "보조 문구", + "title_field": "강조표기 문구", + "type_image": "이미지형", + "type_none": "기본형", + "type_text": "강조표기형" + } + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index 52175757..f8b30598 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -46,7 +46,8 @@ "auth_required": true, "params": { "status": "{{_local.templateStatus ?? ''}}", - "keyword": "{{_local.templateKeyword ?? ''}}" + "keyword": "{{_local.templateKeyword ?? ''}}", + "page": "{{_local.templatePage ?? 1}}" }, "onSuccess": { "handler": "setState", @@ -58,12 +59,13 @@ } }, { - "id": "alimtalk_categories", + "id": "templates_readiness", "type": "api", - "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness", "method": "GET", - "auto_fetch": false, - "auth_required": true + "auto_fetch": true, + "auth_required": true, + "if": "{{(query.tab ?? 'connection') === 'templates'}}" } ], "slots": { @@ -125,10 +127,6 @@ "type": "click", "handler": "sequence", "actions": [ - { - "handler": "setState", - "params": { "target": "local", "templateView": "list" } - }, { "handler": "navigate", "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "templates" }, "mergeQuery": true, "replace": true } @@ -155,40 +153,42 @@ "trackChanges": true, "children": [ { - "id": "info_panel", - "type": "basic", - "name": "Div", - "props": { - "className": "mt-6 alert-info" - }, - "children": [ - { + "id": "preparation_notice", + "comment": "사용 전 준비 안내(통합 1박스) — 실제 비즈뿌리오 사전 준비 절차. 박스 제목 없이 채널 그룹 2개만(문자·카카오)로 위계 단순화. 문자(발신번호 1줄) → 카카오 알림톡(채널·발신프로필 / 템플릿 등록·승인 / 고객센터 API 키 요청 3줄) 순. 각 채널은 라벨(굵게) + 들여쓴 하위 불릿. gap-4 로 그룹 간격.", "type": "basic", "name": "Div", - "props": { "className": "flex gap-3" }, + "props": { "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4 flex flex-col gap-4" }, "children": [ { - "type": "basic", - "name": "Icon", - "props": { - "name": "circle-info", - "size": "md", - "className": "text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" - } + "comment": "문자(SMS/LMS) — 라벨 + 발신번호 등록 1줄", + "type": "basic", "name": "Div", + "props": { "className": "space-y-1" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] } + ] }, { - "type": "basic", - "name": "Div", - "props": { "className": "space-y-1 text-info-soft" }, + "comment": "카카오 알림톡 — 라벨 + 채널/템플릿/API키 3줄", + "type": "basic", "name": "Div", + "props": { "className": "space-y-1" }, "children": [ - { "type": "basic", "name": "P", "text": "$t:sirsoft-message_bizppurio.settings.info.credentials" }, - { "type": "basic", "name": "P", "text": "$t:sirsoft-message_bizppurio.settings.info.report" } + { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] } + ] + }, + { + "type": "basic", "name": "A", + "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 pt-1 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" }, + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" }, + { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } } ] } ] - } - ] - }, + }, { "id": "validation_error", "type": "basic", @@ -930,9 +930,10 @@ "children": [ { "id": "templates_list_error", + "comment": "목록 조회 실패 배너 — 준비 완료(ready)일 때만 노출. 키 미설정(ready=false)이면 아래 readiness 안내 배너가 담당하므로, 같은 원인으로 두 배너가 동시에 뜨지 않게 ready 게이트를 둔다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우에만 카카오가 준 실제 사유를 표시.", "type": "basic", "name": "Div", - "if": "{{_local.templateListError}}", + "if": "{{_local.templateListError && templates_readiness?.data?.ready}}", "props": { "className": "mb-4 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950 p-4 flex gap-2 items-start" }, "children": [ { @@ -964,7 +965,7 @@ "id": "templates_readiness", "type": "basic", "name": "Div", - "if": "{{!(settings?.data?.settings?.api_key) || !(settings?.data?.settings?.sender_key)}}", + "if": "{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}", "props": { "className": "mb-4 rounded-lg border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4" }, "children": [ { @@ -1000,14 +1001,14 @@ { "type": "basic", "name": "Span", - "if": "{{!(settings?.data?.settings?.api_key)}}", + "if": "{{!(templates_readiness?.data?.api_key_set)}}", "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" }, "text": "$t:sirsoft-message_bizppurio.templates.readiness.api_key_missing" }, { "type": "basic", "name": "Span", - "if": "{{!(settings?.data?.settings?.sender_key)}}", + "if": "{{!(templates_readiness?.data?.sender_key_set)}}", "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" }, "text": "$t:sirsoft-message_bizppurio.templates.readiness.sender_key_missing" } @@ -1042,8 +1043,79 @@ "id": "templates_list_view", "type": "basic", "name": "Div", - "if": "{{(_local.templateView ?? 'list') === 'list'}}", "children": [ + { + "id": "templates_list_notice", + "comment": "조회 전용 안내 1박스(파랑). 상단: '상태 배지 읽는 법' 제목 + 배지명(의미색: 발송가능=초록/검수중=amber/미검수·반려=빨강) + 설명. 하단(구분선): 콘솔 위임 1줄 + 콘솔 링크. 제목/본문 sm.", + "type": "basic", + "name": "Div", + "props": { "className": "mb-4 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" }, + "children": [ + { + "comment": "위 — 콘솔 위임 안내 1줄 + 콘솔 링크.", + "type": "basic", "name": "Div", + "props": { "className": "space-y-1" }, + "children": [ + { + "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list_notice.console_desc" } + ] + }, + { + "type": "basic", "name": "A", + "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" }, + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list_notice.console_link" }, + { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } } + ] + } + ] + }, + { + "comment": "구분선 아래 — 상태 배지 의미 3줄. 배지명은 표의 배지와 눈으로 매칭되도록 solid 의미색(다크에서도 뚜렷).", + "type": "basic", "name": "Div", + "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 space-y-1.5" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.title" }, + { + "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, + { "type": "basic", "name": "Span", "children": [ + { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-green-700 dark:text-green-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.sendable_label" }, + { "type": "basic", "name": "Span", "text": " — " }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.sendable" } + ] } + ] + }, + { + "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, + { "type": "basic", "name": "Span", "children": [ + { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-amber-700 dark:text-amber-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.inspecting_label" }, + { "type": "basic", "name": "Span", "text": " — " }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.inspecting" } + ] } + ] + }, + { + "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, + { "type": "basic", "name": "Span", "children": [ + { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-red-700 dark:text-red-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.pending_label" }, + { "type": "basic", "name": "Span", "text": " — " }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.pending" } + ] } + ] + } + ] + } + ] + }, { "id": "templates_toolbar", "type": "basic", @@ -1068,7 +1140,7 @@ "type": "change", "handler": "sequence", "actions": [ - { "handler": "setState", "params": { "target": "local", "templateStatus": "{{$event.target.value}}" } }, + { "handler": "setState", "params": { "target": "local", "templateStatus": "{{$event.target.value}}", "templatePage": 1 } }, { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } ] } @@ -1092,13 +1164,47 @@ "className": "input w-64" }, "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}" } } + { "type": "change", "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}" } }, + { + "comment": "④ 엔터로 검색 실행 — 입력값을 상태에 반영 후 목록 refetch(검색 파라미터 반영)", + "type": "keypress", + "key": "Enter", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}", "templatePage": 1 } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ] + } ] }, { + "comment": "④ 검색 버튼 — 엔터 대신 클릭으로도 검색 실행", "type": "basic", "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary btn-sm" }, + "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" }, + "actions": [ + { "type": "click", "handler": "sequence", "actions": [ + { "handler": "setState", "params": { "target": "local", "templatePage": 1 } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } + ] } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "magnifying-glass", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.search" } + ] + } + ] + }, + { + "comment": "우측 액션 그룹 — 조회 전용(새로고침만). 템플릿 등록은 비즈뿌리오 콘솔로 위임.", + "type": "basic", + "name": "Div", + "props": { "className": "flex gap-2 items-center" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" }, "actions": [ { "type": "click", "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } ], @@ -1108,25 +1214,6 @@ ] } ] - }, - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-primary btn-sm flex-center gap-1.5" }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { "handler": "setState", "params": { "target": "local", "templateView": "form", "templateForm": { "templateEmphasizeType": "NONE", "templateName": "", "templateContent": "", "categoryCode": "" }, "editingCode": null, "templateErrors": null, "templateSaving": false, "templateCodeMode": "auto", "categoryGroup": "", "showVarRef": false, "showOptional": false } }, - { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_categories" } } - ] - } - ], - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "plus", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.new" } - ] } ] }, @@ -1150,10 +1237,13 @@ "type": "basic", "name": "Tr", "children": [ + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-16" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.no" }, { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.name" }, { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.code" }, { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.status" }, - { "type": "basic", "name": "Th", "props": { "className": "text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.actions" } + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-40" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.requested_at" }, + { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-40" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.processed_at" }, + { "type": "basic", "name": "Th", "props": { "className": "text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-16" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.actions" } ] } ] @@ -1164,14 +1254,16 @@ "if": "{{(alimtalk_templates?.data?.templates ?? []).length > 0}}", "iteration": { "source": "alimtalk_templates?.data?.templates ?? []", - "item_var": "tpl" + "item_var": "tpl", + "index_var": "tplIndex" }, "children": [ { "type": "basic", "name": "Tr", - "props": { "className": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/40" }, + "props": { "className": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700" }, "children": [ + { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-gray-500 dark:text-gray-400 tabular-nums" }, "text": "{{((alimtalk_templates?.data?.pagination?.current_page ?? 1) - 1) * (alimtalk_templates?.data?.pagination?.per_page ?? 20) + tplIndex + 1}}" }, { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-medium text-gray-900 dark:text-gray-100" }, "text": "{{tpl.templateName ?? ''}}" }, { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400" }, "text": "{{tpl.templateCode ?? ''}}" }, { @@ -1183,26 +1275,50 @@ "type": "basic", "name": "Span", "props": { - "className": "inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{tpl.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : tpl.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : tpl.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : tpl.status_badge?.variant === 'purple' ? 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}}" + "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium {{tpl.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : tpl.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : tpl.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : tpl.status_badge?.variant === 'purple' ? 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}}" }, - "text": "{{$t(tpl.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" + "children": [ + { "type": "basic", "name": "Span", "text": "{{$t(tpl.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" }, + { "type": "basic", "name": "Span", "if": "{{tpl.service_status === 'RDY'}}", "props": { "className": "opacity-70" }, "text": "$t:sirsoft-message_bizppurio.templates.status_sub.rdy" } + ] } ] }, + { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" }, "text": "{{tpl.createdAt ?? '—'}}" }, + { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" }, "text": "{{tpl.modifiedAt ?? '—'}}" }, { "type": "basic", "name": "Td", - "props": { "className": "px-4 py-3 text-right" }, + "props": { "className": "px-4 py-3 w-20" }, "children": [ { "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-ghost btn-xs" }, - "actions": [ - { "type": "click", "handler": "apiCall", "auth_required": true, "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{{tpl.templateCode}}", "params": { "method": "GET" }, "onSuccess": [ { "handler": "setState", "params": { "target": "local", "templateDetail": "{{response.data.template}}" } }, { "handler": "openModal", "target": "alimtalk_template_detail_modal" } ] } - ], + "name": "Div", + "props": { "className": "flex items-center justify-center" }, "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.actions.detail" } + { + "comment": "조회 전용 — [내용] 버튼 하나. 상세 조회 후 내용 모달 열기(관리 액션 없음).", + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary btn-xs flex-center gap-1" }, + "actions": [ + { + "type": "click", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{{tpl.templateCode}}", + "params": { "method": "GET" }, + "onSuccess": [ + { "handler": "setState", "params": { "target": "local", "templateDetail": "{{response.data.template}}" } }, + { "handler": "openModal", "target": "alimtalk_template_detail_modal" } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "eye", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.actions.detail" } + ] + } ] } ] @@ -1223,7 +1339,7 @@ { "type": "basic", "name": "Td", - "props": { "className": "px-4 py-12 text-center text-gray-400 dark:text-gray-500", "colSpan": 4 }, + "props": { "className": "px-4 py-12 text-center text-gray-400 dark:text-gray-500", "colSpan": 7 }, "children": [ { "type": "basic", "name": "P", "props": { "className": "font-medium" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty" }, { "type": "basic", "name": "P", "props": { "className": "text-xs mt-1" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty_hint" } @@ -1236,770 +1352,29 @@ ] } ] - } - ] - }, - { - "id": "templates_form_view", - "type": "basic", - "name": "Div", - "if": "{{_local.templateView === 'form'}}", - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex-between mb-4" }, - "children": [ - { - "type": "basic", - "name": "H3", - "props": { "className": "section-heading-md text-gray-900 dark:text-gray-100" }, - "text": "{{_local.editingCode ? $t('sirsoft-message_bizppurio.templates.form.title_edit') : $t('sirsoft-message_bizppurio.templates.form.title_new')}}" - }, - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "btn btn-ghost btn-sm" }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "templateView": "list" } } - ], - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "arrow-left", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.back_to_list" } - ] - } - ] }, { + "comment": "페이지네이션 — 항상 노출(총 1페이지여도 표시). 페이지 변경 시 _local.templatePage 갱신 후 목록 refetch.", "type": "basic", "name": "Div", - "props": { "className": "grid grid-cols-1 lg:grid-cols-2 gap-6" }, + "props": { "className": "flex justify-center mt-4" }, "children": [ { - "id": "template_form_fields", - "type": "basic", - "name": "Div", - "props": { "className": "space-y-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6" }, - "children": [ + "type": "composite", + "name": "Pagination", + "props": { + "currentPage": "{{alimtalk_templates?.data?.pagination?.current_page ?? 1}}", + "totalPages": "{{alimtalk_templates?.data?.pagination?.total_page ?? 1}}" + }, + "actions": [ { - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name" }, - { - "type": "basic", - "name": "Input", - "props": { "type": "text", "maxLength": 200, "className": "input", "value": "{{_local.templateForm?.templateName ?? ''}}" }, - "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateName": "{{$event.target.value}}" } } - ] - }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name_hint" } + "event": "onPageChange", + "type": "action", + "handler": "sequence", + "actions": [ + { "handler": "setState", "params": { "target": "local", "templatePage": "{{$args[0]}}" } }, + { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } ] - }, - { - "id": "template_code_field", - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code" }, - { - "type": "basic", - "name": "Div", - "props": { "className": "inline-flex gap-2 mt-1 mb-2" }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateCodeMode ?? 'auto') === 'auto' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "templateCodeMode": "auto", "templateForm.templateCode": "" } } - ], - "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.code_auto" } ] - }, - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateCodeMode ?? 'auto') === 'manual' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "templateCodeMode": "manual" } } - ], - "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.code_manual" } ] - } - ] - }, - { - "type": "basic", - "name": "Input", - "if": "{{_local.templateCodeMode === 'manual'}}", - "props": { "type": "text", "maxLength": 30, "className": "input font-mono", "placeholder": "TW_1234", "value": "{{_local.templateForm?.templateCode ?? ''}}" }, - "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateCode": "{{$event.target.value}}" } } - ] - }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code_hint" } - ] - }, - { - "id": "template_category_field", - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category" }, - { - "type": "basic", - "name": "Div", - "props": { "className": "grid grid-cols-2 gap-2" }, - "children": [ - { - "type": "basic", - "name": "Select", - "props": { "className": "input", "value": "{{_local.categoryGroup ?? ''}}" }, - "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "categoryGroup": "{{$event.target.value}}", "templateForm.categoryCode": "" } } - ], - "children": [ - { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category_group" }, - { - "type": "basic", - "name": "Option", - "iteration": { - "source": "[...new Set((alimtalk_categories?.data?.categories ?? []).map(c => c.groupName))]", - "item_var": "grp" - }, - "props": { "value": "{{grp}}" }, - "text": "{{grp}}" - } - ] - }, - { - "type": "basic", - "name": "Select", - "props": { "className": "input", "disabled": "{{!_local.categoryGroup}}", "value": "{{_local.templateForm?.categoryCode ?? ''}}" }, - "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.categoryCode": "{{$event.target.value}}" } } - ], - "children": [ - { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.form.category_item" }, - { - "type": "basic", - "name": "Option", - "iteration": { - "source": "(alimtalk_categories?.data?.categories ?? []).filter(c => c.groupName === _local.categoryGroup)", - "item_var": "cat" - }, - "props": { "value": "{{cat.code}}" }, - "text": "{{cat.name}}" - } - ] - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.emphasize_type" }, - { - "type": "basic", - "name": "Div", - "props": { "className": "inline-flex gap-2 mt-1" }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" - }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "NONE" } } - ], - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_none" } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" - }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "IMAGE" } } - ], - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_image" } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { - "type": "button", - "className": "px-3 py-1.5 rounded-lg text-sm font-medium border transition-colors whitespace-nowrap {{(_local.templateForm?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}}" - }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateEmphasizeType": "TEXT" } } - ], - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.type_text" } - ] - } - ] - } - ] - }, - { - "id": "template_text_fields", - "type": "basic", - "name": "Div", - "if": "{{_local.templateForm?.templateEmphasizeType === 'TEXT'}}", - "props": { "className": "space-y-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.title_field" }, - { - "type": "basic", - "name": "Input", - "props": { "type": "text", "maxLength": 23, "className": "input", "value": "{{_local.templateForm?.templateTitle ?? ''}}" }, - "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateTitle": "{{$event.target.value}}" } } - ] - }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.title_hint" } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.subtitle_field" }, - { - "type": "basic", - "name": "Input", - "props": { "type": "text", "maxLength": 18, "className": "input", "value": "{{_local.templateForm?.templateSubtitle ?? ''}}" }, - "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateSubtitle": "{{$event.target.value}}" } } - ] - }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.subtitle_hint" } - ] - } - ] - }, - { - "id": "template_image_fields", - "type": "basic", - "name": "Div", - "if": "{{_local.templateForm?.templateEmphasizeType === 'IMAGE'}}", - "props": { "className": "space-y-3 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, - "children": [ - { - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_upload" }, - { - "type": "basic", - "name": "Input", - "props": { "type": "file", "accept": ".jpg,.jpeg,.png", "className": "block w-full text-sm text-gray-600 dark:text-gray-300 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:bg-blue-50 dark:file:bg-blue-900/40 file:text-blue-700 dark:file:text-blue-300 file:cursor-pointer" }, - "actions": [ - { "type": "change", "handler": "sirsoft-message_bizppurio.uploadTemplateImage" } - ] - }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_upload_hint" } - ] - }, - { - "type": "basic", - "name": "P", - "if": "{{_local.imageUploading}}", - "props": { "className": "text-sm text-blue-600 dark:text-blue-400 flex items-center gap-1.5" }, - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "spinner", "className": "spinner-sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.image_uploading" } - ] - }, - { - "type": "basic", - "name": "P", - "if": "{{_local.imageUploadError}}", - "props": { "className": "text-sm text-red-600 dark:text-red-400" }, - "text": "{{_local.imageUploadError}}" - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.templateForm?.templateImageUrl}}", - "props": { "className": "flex items-center gap-3 p-2 rounded bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600" }, - "children": [ - { "type": "basic", "name": "Img", "props": { "src": "{{_local.templateForm?.templateImageUrl}}", "className": "w-16 h-16 object-cover rounded" } }, - { - "type": "basic", - "name": "Div", - "props": { "className": "min-w-0 flex-1" }, - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-700 dark:text-gray-200 truncate" }, "text": "{{_local.templateForm?.templateImageName ?? ''}}" }, - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "text-xs text-red-600 dark:text-red-400 underline mt-0.5" }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "templateForm.templateImageUrl": "", "templateForm.templateImageName": "" } } - ], - "children": [ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.image_remove" } ] - } - ] - } - ] - }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.image_maker_notice" } - ] - }, - { - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Label", "props": { "className": "form-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content" }, - { - "type": "basic", - "name": "Textarea", - "props": { - "rows": 14, - "maxLength": 1300, - "placeholder": "$t:sirsoft-message_bizppurio.templates.form.content_placeholder", - "className": "textarea font-mono resize-y leading-relaxed", - "value": "{{_local.templateForm?.templateContent ?? ''}}" - }, - "actions": [ - { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateContent": "{{$event.target.value}}" } } - ] - }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content_hint" } - ] - }, - { - "id": "template_variables", - "type": "basic", - "name": "Div", - "props": { "className": "rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 p-3 space-y-2" }, - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm font-medium text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_title" }, - { "type": "basic", "name": "P", "props": { "className": "text-xs text-blue-800 dark:text-blue-300 leading-relaxed" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_help" }, - { "type": "basic", "name": "P", "props": { "className": "text-xs text-blue-700 dark:text-blue-300 mt-1" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_quick" }, - { - "type": "basic", - "name": "Div", - "props": { "className": "flex gap-1.5 flex-wrap" }, - "iteration": { - "source": "['name', 'app_name', 'order_number', 'site_url']", - "item_var": "varName" - }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "px-2 py-1 rounded bg-white dark:bg-gray-800 border border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300 text-xs font-mono hover:bg-blue-100 dark:hover:bg-blue-900/50" }, - "actions": [ - { "type": "click", "handler": "sirsoft-message_bizppurio.insertVariable", "params": { "variable": "{{varName}}", "target": "templateContent", "statePath": "templateForm.templateContent" } } - ], - "children": [ - { "type": "basic", "name": "Span", "text": "{{'#{' + varName + '}'}}" } - ] - } - ] - }, - { - "type": "basic", - "name": "P", - "props": { "className": "text-xs font-medium text-blue-800 dark:text-blue-300 mt-2" }, - "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_toggle" - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "overflow-x-auto mt-1 rounded border border-blue-100 dark:border-blue-800 bg-white/60 dark:bg-gray-900/40" }, - "children": [ - { - "type": "basic", - "name": "Table", - "props": { "className": "w-full text-xs" }, - "children": [ - { - "type": "basic", - "name": "Thead", - "children": [ - { - "type": "basic", - "name": "Tr", - "children": [ - { "type": "basic", "name": "Th", "props": { "className": "text-left px-2 py-1 text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_event" }, - { "type": "basic", "name": "Th", "props": { "className": "text-left px-2 py-1 text-blue-900 dark:text-blue-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.variables_ref_vars" } - ] - } - ] - }, - { - "type": "basic", - "name": "Tbody", - "iteration": { - "source": "[{event: $t('sirsoft-message_bizppurio.templates.varref.welcome'), vars: '#{name} #{app_name} #{action_url} #{site_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.reset_password'), vars: '#{name} #{app_name} #{action_url} #{expire_minutes} #{site_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.order_confirmed'), vars: '#{name} #{app_name} #{order_number} #{total_amount} #{order_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.order_shipped'), vars: '#{name} #{app_name} #{order_number} #{carrier_name} #{tracking_number} #{order_url}'}, {event: $t('sirsoft-message_bizppurio.templates.varref.new_comment'), vars: '#{name} #{app_name} #{board_name} #{post_title} #{comment_author} #{post_url}'}]", - "item_var": "vref" - }, - "children": [ - { - "type": "basic", - "name": "Tr", - "props": { "className": "border-t border-blue-100 dark:border-blue-800" }, - "children": [ - { "type": "basic", "name": "Td", "props": { "className": "px-2 py-1 text-blue-800 dark:text-blue-300 whitespace-nowrap" }, "text": "{{vref.event}}" }, - { "type": "basic", "name": "Td", "props": { "className": "px-2 py-1 text-blue-700 dark:text-blue-300 font-mono" }, "text": "{{vref.vars}}" } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "id": "template_optional", - "type": "basic", - "name": "Div", - "children": [ - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "className": "text-sm text-gray-600 dark:text-gray-300 flex items-center gap-1" }, - "actions": [ - { "type": "click", "handler": "setState", "params": { "target": "local", "showOptional": "{{!_local.showOptional}}" } } - ], - "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "{{_local.showOptional ? 'chevron-down' : 'chevron-right'}}", "size": "sm" } }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.optional_toggle" } - ] - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.showOptional}}", - "props": { "className": "mt-2 space-y-2 p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600" }, - "children": [ - { "type": "basic", "name": "P", "props": { "className": "form-hint mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.form.optional_help" }, - { - "type": "basic", - "name": "Div", - "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optExtra}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optExtra": "{{$event.target.checked}}", "templateForm.templateExtra": "" } } ] }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.extra" } - ] - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.optExtra}}", - "props": { "className": "mt-1.5 pl-6" }, - "children": [ - { "type": "basic", "name": "Textarea", "props": { "rows": 2, "maxLength": 500, "className": "textarea resize-y", "value": "{{_local.templateForm?.templateExtra ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateExtra": "{{$event.target.value}}" } } ] }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.extra_hint" } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optPreview}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optPreview": "{{$event.target.checked}}", "templateForm.templatePreviewMessage": "" } } ] }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.preview_message" } - ] - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.optPreview}}", - "props": { "className": "mt-1.5 pl-6" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 40, "className": "input", "value": "{{_local.templateForm?.templatePreviewMessage ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templatePreviewMessage": "{{$event.target.value}}" } } ] }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.preview_message_hint" } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optButton}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optButton": "{{$event.target.checked}}", "templateForm.buttons": [] } } ] }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.button" } - ] - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.optButton}}", - "props": { "className": "mt-1.5 pl-6 grid grid-cols-1 sm:grid-cols-3 gap-2" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 20, "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_name", "value": "{{_local.templateForm?.buttons?.[0]?.name ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.buttons.0.name": "{{$event.target.value}}", "templateForm.buttons.0.linkType": "WL" } } ] }, - { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input sm:col-span-2", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_url", "value": "{{_local.templateForm?.buttons?.[0]?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.buttons.0.linkMo": "{{$event.target.value}}", "templateForm.buttons.0.linkType": "WL" } } ] }, - { "type": "basic", "name": "P", "props": { "className": "form-hint sm:col-span-3" }, "text": "$t:sirsoft-message_bizppurio.templates.form.button_hint" } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optQuick}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optQuick": "{{$event.target.checked}}", "templateForm.quickReplies": [] } } ] }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.quick_reply" } - ] - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.optQuick}}", - "props": { "className": "mt-1.5 pl-6 grid grid-cols-1 sm:grid-cols-3 gap-2" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "text", "maxLength": 20, "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_name", "value": "{{_local.templateForm?.quickReplies?.[0]?.name ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.quickReplies.0.name": "{{$event.target.value}}", "templateForm.quickReplies.0.linkType": "WL" } } ] }, - { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input sm:col-span-2", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.button_url", "value": "{{_local.templateForm?.quickReplies?.[0]?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.quickReplies.0.linkMo": "{{$event.target.value}}", "templateForm.quickReplies.0.linkType": "WL" } } ] }, - { "type": "basic", "name": "P", "props": { "className": "form-hint sm:col-span-3" }, "text": "$t:sirsoft-message_bizppurio.templates.form.quick_reply_hint" } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "border-b border-gray-200 dark:border-gray-600 pb-2" }, - "children": [ - { - "type": "basic", - "name": "Label", - "props": { "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-200" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "checkbox", "checked": "{{!!_local.optLink}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "optLink": "{{$event.target.checked}}", "templateForm.templateRepresentLink": {} } } ] }, - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.represent_link" } - ] - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.optLink}}", - "props": { "className": "mt-1.5 pl-6 space-y-2" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.link_pc", "value": "{{_local.templateForm?.templateRepresentLink?.linkPc ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateRepresentLink.linkPc": "{{$event.target.value}}" } } ] }, - { "type": "basic", "name": "Input", "props": { "type": "text", "className": "input", "placeholder": "$t:sirsoft-message_bizppurio.templates.form.link_mo", "value": "{{_local.templateForm?.templateRepresentLink?.linkMo ?? ''}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.templateRepresentLink.linkMo": "{{$event.target.value}}" } } ] } - ] - } - ] - }, - { - "type": "basic", - "name": "Label", - "props": { "className": "flex items-start gap-2 cursor-pointer pt-1" }, - "children": [ - { "type": "basic", "name": "Input", "props": { "type": "checkbox", "className": "mt-0.5", "checked": "{{!!_local.templateForm?.securityFlag}}" }, "actions": [ { "type": "change", "handler": "setState", "params": { "target": "local", "templateForm.securityFlag": "{{$event.target.checked}}" } } ] }, - { - "type": "basic", - "name": "Div", - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-700 dark:text-gray-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.security_flag" }, - { "type": "basic", "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.templates.form.security_flag_hint" } - ] - } - ] - } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "if": "{{_local.templateErrors}}", - "props": { "className": "alert-danger" }, - "children": [ - { - "type": "basic", - "name": "Ul", - "props": { "className": "list-disc list-inside space-y-1" }, - "iteration": { - "source": "Object.values(_local.templateErrors ?? {}).flatMap(m => Array.isArray(m) ? m : [m])", - "item_var": "msg" - }, - "children": [ - { "type": "basic", "name": "Li", "props": { "className": "text-error-soft text-sm" }, "text": "{{msg}}" } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "flex flex-col gap-2 pt-2 border-t border-gray-100 dark:border-gray-700" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex gap-2" }, - "children": [ - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "disabled": "{{_local.templateSaving}}", "className": "btn btn-secondary flex-1" }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { "handler": "setState", "params": { "target": "local", "templateErrors": null, "templateSaving": true } }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", - "params": { "method": "POST", "body": "{{_local.templateForm}}" }, - "onSuccess": [ - { "handler": "setState", "params": { "target": "local", "templateSaving": false } }, - { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.templates.toast.created" } }, - { "handler": "setState", "params": { "target": "local", "templateView": "list" } }, - { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } - ], - "onError": [ - { "handler": "setState", "params": { "target": "local", "templateSaving": false, "templateErrors": "{{error.errors ?? { _general: error.message }}}" } }, - { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.templates.toast.action_failed" } } - ] - } - ] - } - ], - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.save_register" } - ] - }, - { - "type": "basic", - "name": "Button", - "props": { "type": "button", "disabled": "{{_local.templateSaving}}", "className": "btn btn-primary flex-1" }, - "actions": [ - { - "type": "click", - "handler": "sequence", - "actions": [ - { "handler": "setState", "params": { "target": "local", "templateErrors": null, "templateSaving": true } }, - { - "handler": "apiCall", - "auth_required": true, - "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates", - "params": { "method": "POST", "body": "{{Object.assign({}, _local.templateForm, { requestInspection: true })}}" }, - "onSuccess": [ - { "handler": "setState", "params": { "target": "local", "templateSaving": false } }, - { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.templates.toast.created_requested" } }, - { "handler": "setState", "params": { "target": "local", "templateView": "list" } }, - { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } } - ], - "onError": [ - { "handler": "setState", "params": { "target": "local", "templateSaving": false, "templateErrors": "{{error.errors ?? { _general: error.message }}}" } }, - { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.templates.toast.action_failed" } } - ] - } - ] - } - ], - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request" } - ] - } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "rounded-lg bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 p-3 space-y-2 text-xs" }, - "children": [ - { - "type": "basic", - "name": "Div", - "props": { "className": "flex gap-2" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "font-medium text-gray-700 dark:text-gray-200 whitespace-nowrap" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_register" }, - { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_register_hint" } - ] - }, - { - "type": "basic", - "name": "Div", - "props": { "className": "flex gap-2" }, - "children": [ - { "type": "basic", "name": "Span", "props": { "className": "font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request" }, - { "type": "basic", "name": "Span", "props": { "className": "text-gray-500 dark:text-gray-400" }, "text": "$t:sirsoft-message_bizppurio.templates.form.save_and_request_hint" } - ] - } - ] - } - ] - } - ] - }, - { - "id": "template_form_preview", - "type": "basic", - "name": "Div", - "props": { "className": "bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6" }, - "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-label mb-3 text-gray-700 dark:text-gray-200" }, "text": "$t:sirsoft-message_bizppurio.templates.form.preview_title" }, - { - "type": "basic", - "name": "Div", - "if": "{{(_local.templateForm?.templateContent ?? '') !== ''}}", - "props": { "className": "bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-600 p-4 whitespace-pre-wrap text-sm text-gray-900 dark:text-gray-100 leading-relaxed min-h-40" }, - "text": "{{_local.templateForm?.templateContent ?? ''}}" - }, - { - "type": "basic", - "name": "Div", - "if": "{{(_local.templateForm?.templateContent ?? '') === ''}}", - "props": { "className": "bg-white dark:bg-gray-900 rounded-lg border border-dashed border-gray-300 dark:border-gray-600 p-4 text-sm text-gray-400 dark:text-gray-500 min-h-40 flex items-center justify-center text-center" }, - "text": "$t:sirsoft-message_bizppurio.templates.form.preview_empty" - }, - { - "type": "basic", - "name": "P", - "props": { "className": "form-hint mt-3" }, - "text": "$t:sirsoft-message_bizppurio.templates.form.type_item_list_notice" } ] } @@ -2026,41 +1401,154 @@ { "type": "basic", "name": "Div", - "props": { "className": "space-y-3" }, + "props": { "className": "space-y-3 text-gray-900 dark:text-gray-100" }, "children": [ { + "comment": "A안 헤더 — 템플릿명 + 상태 배지(⑦) 한 줄. 배지는 RDY 세부(사용전)까지 표기.", "type": "basic", "name": "Div", + "props": { "className": "flex items-start justify-between gap-3" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.name" }, - { "type": "basic", "name": "P", "props": { "className": "text-body" }, "text": "{{_local.templateDetail?.templateName ?? ''}}" } + { "type": "basic", "name": "H3", "props": { "className": "text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug" }, "text": "{{_local.templateDetail?.templateName ?? ''}}" }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 {{_local.templateDetail?.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : _local.templateDetail?.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : _local.templateDetail?.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : _local.templateDetail?.status_badge?.variant === 'purple' ? 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}}" + }, + "children": [ + { "type": "basic", "name": "Span", "text": "{{$t(_local.templateDetail?.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" }, + { "type": "basic", "name": "Span", "if": "{{_local.templateDetail?.service_status === 'RDY'}}", "props": { "className": "opacity-70" }, "text": "$t:sirsoft-message_bizppurio.templates.status_sub.rdy" } + ] + } ] }, { + "comment": "A안 메타 그리드 — 코드·카테고리·유형·등록요청일·처리일 압축 표시. 없는 값은 '—'.", "type": "basic", "name": "Div", + "props": { "className": "grid grid-cols-2 gap-x-4 gap-y-2 pb-3 border-b border-gray-200 dark:border-gray-700" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.code" }, - { "type": "basic", "name": "P", "props": { "className": "font-mono text-sm text-body" }, "text": "{{_local.templateDetail?.templateCode ?? ''}}" } + { + "type": "basic", "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.code" }, + { "type": "basic", "name": "P", "props": { "className": "font-mono text-sm text-gray-700 dark:text-gray-200 break-all" }, "text": "{{_local.templateDetail?.templateCode ?? '—'}}" } + ] + }, + { + "type": "basic", "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.category" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.categoryName ?? _local.templateDetail?.categoryCode ?? '—'}}" } + ] + }, + { + "type": "basic", "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.emphasize_type" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateEmphasizeType === 'TEXT' ? $t('sirsoft-message_bizppurio.templates.detail.type_text') : _local.templateDetail?.templateEmphasizeType === 'IMAGE' ? $t('sirsoft-message_bizppurio.templates.detail.type_image') : $t('sirsoft-message_bizppurio.templates.detail.type_none')}}" } + ] + }, + { + "type": "basic", "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.requested_at" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.createdAt ?? '—'}}" } + ] + }, + { + "type": "basic", "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.processed_at" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.modifiedAt ?? '—'}}" } + ] + } ] }, { - "type": "basic", - "name": "Div", + "comment": "강조표기형(TEXT)일 때만 — 강조문구/보조문구", + "type": "basic", "name": "Div", + "if": "{{_local.templateDetail?.templateEmphasizeType === 'TEXT'}}", + "props": { "className": "grid grid-cols-2 gap-x-4" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-label" }, "text": "$t:sirsoft-message_bizppurio.templates.form.content" }, - { "type": "basic", "name": "Div", "props": { "className": "bg-gray-50 dark:bg-gray-900/40 rounded p-3 whitespace-pre-wrap text-sm text-body" }, "text": "{{_local.templateDetail?.templateContent ?? ''}}" } + { + "type": "basic", "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.title_field" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateTitle ?? '—'}}" } + ] + }, + { + "type": "basic", "name": "Div", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.subtitle_field" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateSubtitle ?? '—'}}" } + ] + } ] }, { + "comment": "A안 — 내용을 가장 크게 강조", "type": "basic", "name": "Div", - "props": { "className": "flex justify-end pt-2" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.content" }, + { "type": "basic", "name": "Div", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3 whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-100 leading-relaxed" }, "text": "{{_local.templateDetail?.templateContent ?? ''}}" } + ] + }, + { + "comment": "이미지형(IMAGE)일 때만 — 미리보기 이미지", + "type": "basic", "name": "Div", + "if": "{{_local.templateDetail?.templateEmphasizeType === 'IMAGE' && _local.templateDetail?.templateImageUrl}}", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.image_upload" }, + { "type": "basic", "name": "Img", "props": { "src": "{{_local.templateDetail?.templateImageUrl}}", "className": "w-24 h-24 object-cover rounded border border-gray-200 dark:border-gray-700" } } + ] + }, + { + "comment": "버튼 목록 — 있을 때만. 각 버튼 = 이름 + 링크타입 배지 + 링크 URL(있으면).", + "type": "basic", "name": "Div", + "if": "{{(_local.templateDetail?.buttons ?? []).length > 0}}", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.buttons" }, + { + "type": "basic", "name": "Div", + "props": { "className": "space-y-1.5" }, + "iteration": { "source": "_local.templateDetail?.buttons ?? []", "item_var": "btn" }, + "children": [ + { + "type": "basic", "name": "Div", + "props": { "className": "flex items-center gap-2 flex-wrap rounded border border-gray-200 dark:border-gray-700 px-2.5 py-1.5" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-sm font-medium text-gray-800 dark:text-gray-100" }, "text": "{{btn.name ?? ''}}" }, + { "type": "basic", "name": "Span", "props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200 flex-shrink-0" }, "text": "{{$t('sirsoft-message_bizppurio.templates.link_type.' + (btn.linkType ?? 'WL'))}}" }, + { "type": "basic", "name": "Span", "if": "{{btn.linkMo || btn.linkPc || btn.linkAnd || btn.linkIos}}", "props": { "className": "font-mono text-xs text-gray-500 dark:text-gray-400 break-all" }, "text": "{{btn.linkMo || btn.linkPc || btn.linkAnd || btn.linkIos}}" } + ] + } + ] + } + ] + }, + { + "comment": "부가정보 — 있을 때만", + "type": "basic", "name": "Div", + "if": "{{_local.templateDetail?.templateExtra}}", + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.extra" }, + { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-wrap" }, "text": "{{_local.templateDetail?.templateExtra}}" } + ] + }, + { + "comment": "푸터 — 조회 전용. [닫기]만 노출(관리 액션 없음, 등록·상태변경은 비즈뿌리오 콘솔).", + "type": "basic", + "name": "Div", + "props": { "className": "flex items-center justify-end gap-2 pt-3 border-t border-gray-200 dark:border-gray-700" }, "children": [ { "type": "basic", "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary" }, + "props": { "type": "button", "className": "btn btn-secondary flex-shrink-0" }, "actions": [ { "type": "click", "handler": "closeModal", "target": "alimtalk_template_detail_modal" } ], diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php index 47a981f4..14e5e27d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -9,23 +9,20 @@ use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests; -use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreAlimtalkTemplateRequest; -use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateAlimtalkTemplateRequest; -use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UploadTemplateImageRequest; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; /** - * 알림톡 템플릿 관리 컨트롤러 (Phase 5). + * 알림톡 템플릿 조회 컨트롤러 (Phase 5). * - * 카카오 관리 API(kapi)로 알림톡 템플릿을 실시간 조회·등록·수정·삭제·검수·상태변경한다. - * 저장(binding)은 하지 않으며(Phase 6), 목록/상세는 매 요청 실시간 조회한다. + * 카카오 관리 API(kapi)로 알림톡 템플릿을 실시간 조회한다(목록·상세·카테고리·발신프로필). + * 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임하며, 이 화면은 조회 + 알림 연결만 + * 담당한다. 템플릿은 DB 에 저장하지 않고 목록/상세를 매 요청 실시간 조회한다. * * 권한(라우트 미들웨어): * - 조회(list/detail/categories/profiles): sirsoft-message_bizppurio.messaging.view - * - 변경(store/update/destroy/검수/상태변경): sirsoft-message_bizppurio.messaging.manage * * kapi 실패는 BizppurioApiException 으로 전달되므로, 각 액션에서 catch 하여 카카오가 준 - * 실패 사유(message)를 그대로 422 로 반환한다(운영자가 반려/차단 사유를 바로 확인). + * 실패 사유(message)를 그대로 422 로 반환한다(운영자가 조회 실패 원인을 바로 확인). */ class AlimtalkTemplateController extends AdminBaseController { @@ -99,190 +96,7 @@ class AlimtalkTemplateController extends AdminBaseController ])); } - /** - * 이미지형 템플릿용 이미지를 업로드하고 카카오 등록 URL 을 반환합니다. - * - * 업로드된 임시 파일을 카카오 이미지 업로드 API 로 전달하고, 반환된 URL 을 프론트가 - * templateImageUrl 로 사용한다. - * - * @param UploadTemplateImageRequest $request 이미지 파일 검증 Request - * @return JsonResponse data 에 image_url·image_name - */ - public function uploadImage(UploadTemplateImageRequest $request): JsonResponse - { - return $this->guard(function () use ($request) { - $file = $request->file('image'); - - $url = $this->service->uploadImage( - $file->getRealPath(), - $file->getClientOriginalName(), - ); - - return ResponseHelper::success('messages.success', [ - 'image_url' => $url, - 'image_name' => $file->getClientOriginalName(), - ]); - }); - } - - /** - * 알림톡 템플릿을 신규 등록합니다. - * - * @param StoreAlimtalkTemplateRequest $request 유형별 검증 Request - * @return JsonResponse data.template 에 등록된 템플릿(검수요청 시 REQ 상태) - */ - public function store(StoreAlimtalkTemplateRequest $request): JsonResponse - { - return $this->guard(function () use ($request) { - $data = $request->validated(); - $requestInspection = (bool) ($data['requestInspection'] ?? false); - - $template = $this->service->create($data); - - // [등록 후 검수요청]: 등록으로 받은 템플릿 코드로 즉시 검수를 요청한다. - if ($requestInspection) { - $templateCode = (string) ($template['templateCode'] ?? ''); - if ($templateCode !== '') { - $this->service->requestInspection($templateCode); - } - - return ResponseHelper::success( - 'sirsoft-message_bizppurio::messages.template.created_requested', - ['template' => $template], - ); - } - - return ResponseHelper::success( - 'sirsoft-message_bizppurio::messages.template.created', - ['template' => $template], - ); - }); - } - - /** - * 알림톡 템플릿을 수정합니다. (대기 + 검수 REG/REJ 상태만) - * - * @param UpdateAlimtalkTemplateRequest $request 유형별 검증 Request - * @param string $templateCode 기존 템플릿 코드 - * @return JsonResponse data.template 에 수정된 템플릿 - */ - public function update(UpdateAlimtalkTemplateRequest $request, string $templateCode): JsonResponse - { - return $this->guard(fn () => ResponseHelper::success( - 'sirsoft-message_bizppurio::messages.template.updated', - ['template' => $this->service->update($templateCode, $request->validated())], - )); - } - - /** - * 알림톡 템플릿을 삭제합니다. (대기 + 검수 REG/REJ 상태만) - * - * @param string $templateCode 템플릿 코드 - * @return JsonResponse 삭제 완료 응답 - */ - public function destroy(string $templateCode): JsonResponse - { - return $this->guard(function () use ($templateCode) { - $this->service->delete($templateCode); - - return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.deleted'); - }); - } - - /** - * 검수를 요청합니다. (검수 REG 상태만) - * - * @param Request $request HTTP 요청(comment 선택) - * @param string $templateCode 템플릿 코드 - * @return JsonResponse 검수 요청 완료 응답 - */ - public function requestInspection(Request $request, string $templateCode): JsonResponse - { - return $this->guard(function () use ($request, $templateCode) { - $this->service->requestInspection($templateCode, $request->input('comment')); - - return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.requested'); - }); - } - - /** - * 검수 요청을 취소합니다. (검수 REQ 상태만) - * - * @param string $templateCode 템플릿 코드 - * @return JsonResponse 검수 취소 완료 응답 - */ - public function cancelRequest(string $templateCode): JsonResponse - { - return $this->statusAction($templateCode, 'cancelRequest', 'request_canceled'); - } - - /** - * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) - * - * @param string $templateCode 템플릿 코드 - * @return JsonResponse 중지 완료 응답 - */ - public function stop(string $templateCode): JsonResponse - { - return $this->statusAction($templateCode, 'stop', 'stopped'); - } - - /** - * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) - * - * @param string $templateCode 템플릿 코드 - * @return JsonResponse 중지 해제 완료 응답 - */ - public function reuse(string $templateCode): JsonResponse - { - return $this->statusAction($templateCode, 'reuse', 'resumed'); - } - - /** - * 승인을 취소합니다(재검수 가능 상태로 복귀). (RDY/ACT 상태) - * - * @param string $templateCode 템플릿 코드 - * @return JsonResponse 승인 취소 완료 응답 - */ - public function cancelApproval(string $templateCode): JsonResponse - { - return $this->statusAction($templateCode, 'cancelApproval', 'approval_canceled'); - } - - /** - * 휴면 템플릿을 해제합니다. (DMT 상태) - * - * @param string $templateCode 템플릿 코드 - * @return JsonResponse 휴면 해제 완료 응답 - */ - public function release(string $templateCode): JsonResponse - { - return $this->statusAction($templateCode, 'release', 'released'); - } - - /** - * senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리. - * - * @param string $templateCode 템플릿 코드 - * @param string $method 서비스 메서드명 - * @param string $messageKey 성공 메시지 키(messages.template.*) - */ - private function statusAction(string $templateCode, string $method, string $messageKey): JsonResponse - { - return $this->guard(function () use ($templateCode, $method, $messageKey) { - $this->service->{$method}($templateCode); - - return ResponseHelper::success("sirsoft-message_bizppurio::messages.template.{$messageKey}"); - }); - } - - /** - * kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환합니다. - * - * 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 반려/차단/상태오류 원인을 - * 즉시 파악하게 한다. errors 에 result_code 를 실어 프론트가 코드 분기 가능. - * - * @param callable(): JsonResponse $callback 실제 처리 - */ - // guard() 는 GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유). + // kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환하는 guard() 는 + // GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유). + // 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 조회 실패 원인을 즉시 파악한다. } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php deleted file mode 100644 index 60337d20..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php +++ /dev/null @@ -1,111 +0,0 @@ - - */ - public function rules(): array - { - return [ - // 공통 필수 - 'templateName' => ['required', 'string', 'max:200'], - 'templateContent' => ['required', 'string', 'max:1300'], - 'categoryCode' => ['required', 'string', 'max:20'], - 'templateEmphasizeType' => ['required', Rule::in(['NONE', 'IMAGE', 'TEXT'])], - - // 템플릿 코드(선택, 자동 생성 가능). 영문·숫자·언더바·하이픈, 최대 30자 - 'templateCode' => ['nullable', 'string', 'max:30', 'regex:/^[A-Za-z0-9_-]+$/'], - - // 강조표기형(TEXT) 조건부 필수 — 강조표기 문구(변수 가능) + 보조문구(변수 불가) - 'templateTitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:23'], - 'templateSubtitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:18'], - - // 이미지형(IMAGE) 조건부 필수 — 이미지 파일명 + 링크 - 'templateImageName' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'max:255'], - 'templateImageUrl' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'url', 'max:500'], - - // 선택 항목(§6-4): 부가정보 · 미리보기 메시지 · 템플릿 보안 - 'templatePreviewMessage' => ['nullable', 'string', 'max:40'], - 'templateExtra' => ['nullable', 'string', 'max:500'], - 'securityFlag' => ['nullable', 'boolean'], - - // 등록 후 즉시 검수요청 여부([등록 후 검수요청] 버튼) — 서비스가 소비, kapi payload 제외 - 'requestInspection' => ['nullable', 'boolean'], - - // 버튼(최대 5) - 'buttons' => ['nullable', 'array', 'max:5'], - 'buttons.*.name' => ['required_with:buttons', 'string', 'max:20'], - 'buttons.*.linkType' => ['required_with:buttons', 'string', 'max:4'], - 'buttons.*.linkMo' => ['nullable', 'string', 'max:500'], - 'buttons.*.linkPc' => ['nullable', 'string', 'max:500'], - 'buttons.*.linkAnd' => ['nullable', 'string', 'max:500'], - 'buttons.*.linkIos' => ['nullable', 'string', 'max:500'], - - // 바로연결(최대 10) - 'quickReplies' => ['nullable', 'array', 'max:10'], - 'quickReplies.*.name' => ['required_with:quickReplies', 'string', 'max:20'], - 'quickReplies.*.linkType' => ['required_with:quickReplies', 'string', 'max:4'], - - // 대표 링크(선택) — PC/모바일 웹, 앱 스킴 - 'templateRepresentLink' => ['nullable', 'array'], - 'templateRepresentLink.linkPc' => ['nullable', 'string', 'max:500'], - 'templateRepresentLink.linkMo' => ['nullable', 'string', 'max:500'], - 'templateRepresentLink.linkAnd' => ['nullable', 'string', 'max:500'], - 'templateRepresentLink.linkIos' => ['nullable', 'string', 'max:500'], - ]; - } - - /** - * 검증 속성 라벨(다국어). - * - * @return array - */ - public function attributes(): array - { - return [ - 'templateName' => __('sirsoft-message_bizppurio::messages.template.fields.name'), - 'templateContent' => __('sirsoft-message_bizppurio::messages.template.fields.content'), - 'categoryCode' => __('sirsoft-message_bizppurio::messages.template.fields.category'), - 'templateEmphasizeType' => __('sirsoft-message_bizppurio::messages.template.fields.emphasize_type'), - 'templateCode' => __('sirsoft-message_bizppurio::messages.template.fields.code'), - 'templateTitle' => __('sirsoft-message_bizppurio::messages.template.fields.title'), - 'templateSubtitle' => __('sirsoft-message_bizppurio::messages.template.fields.subtitle'), - 'templateImageName' => __('sirsoft-message_bizppurio::messages.template.fields.image_name'), - 'templateImageUrl' => __('sirsoft-message_bizppurio::messages.template.fields.image_url'), - ]; - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php deleted file mode 100644 index 0e6a0063..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateAlimtalkTemplateRequest.php +++ /dev/null @@ -1,16 +0,0 @@ - - */ - public function rules(): array - { - return [ - // jpg/png, 최대 500KB (카카오 이미지형 제한) - 'image' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:500'], - ]; - } - - /** - * 검증 속성 라벨(다국어). - * - * @return array - */ - public function attributes(): array - { - return [ - 'image' => __('sirsoft-message_bizppurio::messages.template.fields.image'), - ]; - } -} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php index 7a71e4f2..e17ff602 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php @@ -4,28 +4,25 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Services; -use App\ActivityLog\Traits\ResolvesActivityLogType; use App\Services\PluginSettingsService; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; /** - * 알림톡 템플릿 관리 서비스 (Phase 5). + * 알림톡 템플릿 조회 서비스 (Phase 5). * - * 카카오 관리 API(kapi.ppurio.com)를 BizppurioKakaoApiClient 로 위임하여 알림톡 템플릿의 - * 실시간 조회·등록·수정·삭제·검수(요청/취소)·상태변경(중지/해제/승인취소)을 수행한다. - * 템플릿은 DB 에 저장하지 않고 매 요청 실시간으로 조회한다(계획서 §6-3). + * 카카오 관리 API(kapi.ppurio.com)를 BizppurioKakaoApiClient 로 위임하여 알림톡 템플릿을 + * 실시간 조회한다(목록·상세·카테고리·발신프로필). 등록·수정·삭제·검수·상태변경은 비즈뿌리오 + * 콘솔로 위임하며, 이 화면은 목록·상태·내용 조회 + 알림 연결만 담당한다. 템플릿은 DB 에 + * 저장하지 않고 매 요청 실시간으로 조회한다(계획서 §6-3). * - * 이 서비스는 다음 두 가지 도메인 로직을 담당한다. - * - serviceStatus(REG/REQ/REJ/RDY/ACT/DMT/STP/BLK) → 상태 배지 + 상태별 가능 액션 매핑 - * - templateMessageType(BA/EX/AD/MI) 백엔드 자동 계산(부가정보·채널추가 버튼 유무로 결정) + * 이 서비스는 serviceStatus(REG/REQ/REJ/RDY/ACT/DMT/STP/BLK)를 상태 배지로 매핑하는 + * 도메인 로직을 담당한다(RDY/ACT 만 알림 연결 가능 상태). * * 발신프로필 키(senderKey)는 환경설정(sender_key)에서 가져오며, 미설정 시 조회 자체가 * 불가능하므로 화면은 readiness 로 사전 안내한다(§6-3). */ class AlimtalkTemplateService { - use ResolvesActivityLogType; - /** 플러그인 식별자 (manifest 와 일치) */ private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; @@ -51,22 +48,6 @@ class AlimtalkTemplateService 'DMT' => ['label_key' => 'dormant', 'variant' => 'purple'], ]; - /** - * serviceStatus → 상태별 가능 액션(정본 표, §6-3). - * - * @var array> - */ - private const STATUS_ACTIONS = [ - 'REG' => ['edit', 'delete', 'request'], - 'REQ' => ['cancel_request'], - 'REJ' => ['edit', 'delete'], - 'RDY' => ['stop', 'cancel_approval'], - 'ACT' => ['stop', 'cancel_approval'], - 'STP' => ['reuse'], - 'DMT' => ['release'], - 'BLK' => [], - ]; - /** * @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트 * @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key) @@ -166,330 +147,13 @@ class AlimtalkTemplateService } /** - * 이미지형 템플릿용 이미지를 카카오 서버에 업로드하고 등록 URL 을 반환합니다. - * - * 업로드 성공 시 카카오가 반환한 이미지 URL 을 `templateImageUrl` 로 사용한다. - * - * @param string $filePath 임시 업로드 파일 절대 경로 - * @param string $fileName 원본 파일명 - * @return string 카카오 서버에 등록된 이미지 URL - * - * @throws BizppurioApiException 업로드 실패 시 - */ - public function uploadImage(string $filePath, string $fileName): string - { - $response = $this->kakao->uploadTemplateImage($filePath, $fileName); - - $this->assertSuccess($response); - - return (string) ($response['image'] ?? ''); - } - - /** - * 알림톡 템플릿을 신규 등록합니다. - * - * senderKey·templateMessageType 은 서비스가 채우며(FormRequest 는 화면 입력만 검증), - * messageType 은 부가정보/채널추가 버튼 유무로 자동 계산한다. - * - * @param array $data 검증된 템플릿 입력 - * @return array 등록 결과(템플릿 상세) - * - * @throws BizppurioApiException 등록 실패 시 - */ - public function create(array $data): array - { - $payload = $this->buildPayload($data); - - $response = $this->kakao->request('/v3/kakao/template/add', $payload); - - $this->assertSuccess($response); - - $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.create', [ - 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_create', - 'description_params' => ['template_name' => (string) ($data['templateName'] ?? '')], - ]); - - return (array) ($response['data'] ?? []); - } - - /** - * 알림톡 템플릿을 수정합니다. (대기(R) + 검수 REG/REJ 상태만 가능) - * - * @param string $templateCode 기존 템플릿 코드 - * @param array $data 검증된 템플릿 입력 - * @return array 수정 결과(템플릿 상세) - * - * @throws BizppurioApiException 수정 실패 시 - */ - public function update(string $templateCode, array $data): array - { - $payload = array_merge($this->buildPayload($data), [ - 'templateCode' => $templateCode, - ]); - - $response = $this->kakao->request('/v3/kakao/template/update', $payload); - - $this->assertSuccess($response); - - $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.update', [ - 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_update', - 'description_params' => ['template_code' => $templateCode], - ]); - - return (array) ($response['data'] ?? []); - } - - /** - * 알림톡 템플릿을 삭제합니다. (대기(R) + 검수 REG/REJ 상태만 가능) - * - * @param string $templateCode 템플릿 코드 - * - * @throws BizppurioApiException 삭제 실패 시 - */ - public function delete(string $templateCode): void - { - $this->simpleAction('/v3/kakao/template/delete', $templateCode, 'delete'); - } - - /** - * 검수를 요청합니다. (대기(R) + 검수 REG 상태만 가능) - * - * @param string $templateCode 템플릿 코드 - * @param string|null $comment 의견/문의(최대 500자) - * - * @throws BizppurioApiException 요청 실패 시 - */ - public function requestInspection(string $templateCode, ?string $comment = null): void - { - $params = [ - 'senderKey' => $this->senderKey(), - 'templateCode' => $templateCode, - ]; - - if ($comment !== null && $comment !== '') { - $params['comment'] = $comment; - } - - $response = $this->kakao->request('/v3/kakao/template/request', $params); - - $this->assertSuccess($response); - - $this->logActivity('sirsoft-message_bizppurio.alimtalk_template.request', [ - 'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_request', - 'description_params' => ['template_code' => $templateCode], - ]); - } - - /** - * 검수 요청을 취소합니다. (검수 REQ 상태만 가능) - * - * @param string $templateCode 템플릿 코드 - * - * @throws BizppurioApiException 취소 실패 시 - */ - public function cancelRequest(string $templateCode): void - { - $this->simpleAction('/v3/kakao/template/cancel_request', $templateCode, 'cancel_request'); - } - - /** - * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) - * - * @param string $templateCode 템플릿 코드 - * - * @throws BizppurioApiException 중지 실패 시 - */ - public function stop(string $templateCode): void - { - $this->simpleAction('/v3/kakao/template/stop', $templateCode, 'stop'); - } - - /** - * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) - * - * @param string $templateCode 템플릿 코드 - * - * @throws BizppurioApiException 해제 실패 시 - */ - public function reuse(string $templateCode): void - { - $this->simpleAction('/v3/kakao/template/reuse', $templateCode, 'reuse'); - } - - /** - * 승인을 취소합니다(재검수 요청 가능 상태로 복귀). (RDY/ACT 상태) - * - * @param string $templateCode 템플릿 코드 - * - * @throws BizppurioApiException 취소 실패 시 - */ - public function cancelApproval(string $templateCode): void - { - $this->simpleAction('/v3/kakao/template/cancel_approval', $templateCode, 'cancel_approval'); - } - - /** - * 휴면 템플릿을 해제합니다. (DMT 상태) - * - * @param string $templateCode 템플릿 코드 - * - * @throws BizppurioApiException 해제 실패 시 - */ - public function release(string $templateCode): void - { - $this->simpleAction('/v3/kakao/template/release', $templateCode, 'release'); - } - - /** - * senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리. - * - * @param string $path kapi 엔드포인트 경로 - * @param string $templateCode 템플릿 코드 - * @param string $action 활동 로그 action 세그먼트 - * - * @throws BizppurioApiException 요청 실패 시 - */ - private function simpleAction(string $path, string $templateCode, string $action): void - { - $response = $this->kakao->request($path, [ - 'senderKey' => $this->senderKey(), - 'templateCode' => $templateCode, - ]); - - $this->assertSuccess($response); - - $this->logActivity("sirsoft-message_bizppurio.alimtalk_template.{$action}", [ - 'description_key' => "sirsoft-message_bizppurio::activity_log.description.alimtalk_template_{$action}", - 'description_params' => ['template_code' => $templateCode], - ]); - } - - /** - * 화면 입력을 kapi 등록/수정 payload 로 조립합니다. - * - * - senderKey 는 환경설정에서 주입 - * - templateMessageType 은 부가정보·채널추가(AC) 버튼 유무로 자동 계산 - * - 빈 선택항목은 payload 에서 제외(kapi 유형별 필수 규칙 위반 방지) - * - * @param array $data 검증된 입력 - * @return array kapi payload (bizId/apiKey 제외 — 클라이언트가 주입) - */ - private function buildPayload(array $data): array - { - $payload = [ - 'senderKey' => $this->senderKey(), - 'senderKeyType' => 'S', - 'templateName' => (string) $data['templateName'], - 'templateContent' => (string) $data['templateContent'], - 'categoryCode' => (string) $data['categoryCode'], - 'templateEmphasizeType' => (string) ($data['templateEmphasizeType'] ?? 'NONE'), - ]; - - if (! empty($data['templateCode'])) { - $payload['templateCode'] = (string) $data['templateCode']; - } - - // 강조표기형(TEXT) - if ($payload['templateEmphasizeType'] === 'TEXT') { - $payload['templateTitle'] = (string) ($data['templateTitle'] ?? ''); - $payload['templateSubtitle'] = (string) ($data['templateSubtitle'] ?? ''); - } - - // 이미지형(IMAGE) - if ($payload['templateEmphasizeType'] === 'IMAGE') { - $payload['templateImageName'] = (string) ($data['templateImageName'] ?? ''); - $payload['templateImageUrl'] = (string) ($data['templateImageUrl'] ?? ''); - } - - // 선택항목(§6-4): 부가정보 · 미리보기 메시지 - foreach (['templatePreviewMessage', 'templateExtra'] as $optional) { - if (! empty($data[$optional])) { - $payload[$optional] = (string) $data[$optional]; - } - } - - if (array_key_exists('securityFlag', $data)) { - $payload['securityFlag'] = (bool) $data['securityFlag']; - } - - // 선택항목: 버튼(최대 5) · 바로연결(최대 10) - $buttons = $this->normalizeList($data['buttons'] ?? []); - if ($buttons !== []) { - $payload['buttons'] = $buttons; - } - - $quickReplies = $this->normalizeList($data['quickReplies'] ?? []); - if ($quickReplies !== []) { - $payload['quickReplies'] = $quickReplies; - } - - // 선택항목: 대표 링크(빈 링크 필드 제거 후 하나라도 있으면 포함) - $representLink = array_filter( - (array) ($data['templateRepresentLink'] ?? []), - static fn ($v) => is_string($v) && $v !== '', - ); - if ($representLink !== []) { - $payload['templateRepresentLink'] = $representLink; - } - - $payload['templateMessageType'] = $this->resolveMessageType($payload); - - return $payload; - } - - /** - * templateMessageType 을 부가정보·채널추가(AC) 버튼 유무로 계산합니다. - * - * - 부가정보(templateExtra) 있음 → EX - * - 채널추가(AC) 버튼 있음 → AD - * - 둘 다 → MI - * - 없음 → BA - * - * @param array $payload 조립 중인 payload - * @return string BA/EX/AD/MI - */ - private function resolveMessageType(array $payload): string - { - $hasExtra = ! empty($payload['templateExtra']); - $hasAddChannel = false; - - foreach ((array) ($payload['buttons'] ?? []) as $button) { - if (($button['linkType'] ?? null) === 'AC') { - $hasAddChannel = true; - break; - } - } - - return match (true) { - $hasExtra && $hasAddChannel => 'MI', - $hasExtra => 'EX', - $hasAddChannel => 'AD', - default => 'BA', - }; - } - - /** - * 버튼/바로연결 배열에서 빈 항목을 제거하고 정규화합니다. - * - * @param mixed $list 입력 배열 - * @return array> - */ - private function normalizeList(mixed $list): array - { - if (! is_array($list)) { - return []; - } - - return array_values(array_filter($list, static fn ($item) => is_array($item) && ! empty($item['name']) && ! empty($item['linkType']))); - } - - /** - * 템플릿 행에 상태 배지·가능 액션을 부가합니다. + * 템플릿 행에 상태 배지를 부가합니다. * * serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다. + * RDY/ACT(승인) 상태만 알림 연결 가능하며, 프론트가 배지로 이를 안내한다. * * @param array $row kapi 템플릿 행 - * @return array 배지·액션이 부가된 행 + * @return array 배지가 부가된 행 */ private function decorate(array $row): array { @@ -498,10 +162,12 @@ class AlimtalkTemplateService $row['service_status'] = $status; $row['status_badge'] = [ - 'label_key' => 'sirsoft-message_bizppurio::messages.template.status.'.$badge['label_key'], + // 프론트 $t() 가 해석하는 프론트 lang 키 형식(templates.status.*)으로 준다. + // 프론트(en/ko.json)에는 이 키만 존재하며, 백엔드 messages.php 네임스페이스 + // (::messages.template.status.*)는 프론트에 없어 원문이 그대로 노출된다. + 'label_key' => 'sirsoft-message_bizppurio.templates.status.'.$badge['label_key'], 'variant' => $badge['variant'], ]; - $row['available_actions'] = self::STATUS_ACTIONS[$status] ?? []; return $row; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php index 53cf7dc8..552c4c99 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php @@ -88,45 +88,6 @@ class BizppurioKakaoApiClient ]); } - /** - * 알림톡 이미지형 템플릿용 이미지를 업로드합니다. (`/v3/kakao/image/alimtalk/template`) - * - * multipart/form-data 로 이미지 파일을 전송하고, 성공 시 카카오 서버에 등록된 이미지 - * URL(`image`)을 반환받는다. 이미지 제한: jpg/png, 최대 500KB, 가로 500px 이상. - * - * @param string $filePath 업로드할 이미지 파일의 절대 경로 - * @param string $fileName 원본 파일명 - * @return array 응답 배열 (code/message/image) - * - * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시 - */ - public function uploadTemplateImage(string $filePath, string $fileName): array - { - [$bizId, $apiKey] = $this->credentials(); - - $response = $this->http() - ->attach('image', file_get_contents($filePath), $fileName) - ->post(self::BASE_URL.'/v3/kakao/image/alimtalk/template', [ - 'bizId' => $bizId, - 'apiKey' => $apiKey, - ]); - - if ($response->failed()) { - throw $this->failureException($response); - } - - $result = $response->json(); - - if (! is_array($result)) { - throw new BizppurioApiException( - __('sirsoft-message_bizppurio::messages.error.invalid_response'), - httpStatus: $response->status(), - ); - } - - return $result; - } - /** * 카카오 관리 API 의 임의 엔드포인트를 호출합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index e92010a0..8f45d301 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -2,6 +2,7 @@ use App\Http\Middleware\EnforceIdentityPolicy; use App\Http\Middleware\RefreshTokenExpiration; +use App\Services\PluginSettingsService; use Illuminate\Support\Facades\Route; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController; @@ -49,40 +50,45 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g })->middleware('permission:admin,core.plugins.read') ->name('report.url'); + // 알림톡 템플릿 화면 준비 상태(값 유무) 조회. + // + // 카카오 관리에 필요한 자격증명(api_key·sender_key)은 sensitive:true 라 코어 설정 조회 + // 응답에서 제거된다(보안). 따라서 프론트가 설정 조회 값으로 "설정됨" 을 판정할 수 없어, + // 값 자체는 노출하지 않고 저장 여부(boolean)만 내려준다. 알림톡 탭 readiness 배너가 소비. + Route::get('/templates-readiness', function () { + $settings = app(PluginSettingsService::class) + ->get('sirsoft-message_bizppurio') ?? []; + + $filled = static fn (string $key): bool => trim((string) ($settings[$key] ?? '')) !== ''; + + return response()->json([ + 'success' => true, + 'data' => [ + 'api_key_set' => $filled('api_key'), + 'sender_key_set' => $filled('sender_key'), + 'ready' => $filled('api_key') && $filled('sender_key'), + ], + ]); + })->middleware('permission:admin,sirsoft-message_bizppurio.messaging.view') + ->name('templates.readiness'); + /* |---------------------------------------------------------------------- - | 알림톡 템플릿 관리 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임 + | 알림톡 템플릿 조회 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임 |---------------------------------------------------------------------- | - | 조회(list/detail/categories/profiles) = messaging.view - | 변경(store/update/destroy/검수/상태변경) = messaging.manage - | 템플릿은 DB 저장 없이 매 요청 실시간 조회. 설정 페이지 알림톡 템플릿 탭이 소비. + | 조회 전용(list/detail/categories/profiles) = messaging.view. + | 템플릿 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임한다(이 화면은 목록·상태· + | 내용 조회 + 알림 연결만 담당). 템플릿은 DB 저장 없이 매 요청 실시간 조회하며, 설정 + | 페이지 알림톡 템플릿 탭이 소비한다. */ Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () { - // 조회 (view) Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { Route::get('/', [AlimtalkTemplateController::class, 'index'])->name('index'); Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories'); Route::get('/profiles', [AlimtalkTemplateController::class, 'profiles'])->name('profiles'); Route::get('/{templateCode}', [AlimtalkTemplateController::class, 'show'])->name('show'); }); - - // 변경 (manage) - Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { - Route::post('/', [AlimtalkTemplateController::class, 'store'])->name('store'); - // 이미지형 템플릿 이미지 업로드 (정적 경로 — {templateCode} 보다 먼저 선언) - Route::post('/image', [AlimtalkTemplateController::class, 'uploadImage'])->name('image'); - Route::put('/{templateCode}', [AlimtalkTemplateController::class, 'update'])->name('update'); - Route::delete('/{templateCode}', [AlimtalkTemplateController::class, 'destroy'])->name('destroy'); - - // 검수·상태 변경 - Route::post('/{templateCode}/request', [AlimtalkTemplateController::class, 'requestInspection'])->name('request'); - Route::post('/{templateCode}/cancel-request', [AlimtalkTemplateController::class, 'cancelRequest'])->name('cancel-request'); - Route::post('/{templateCode}/stop', [AlimtalkTemplateController::class, 'stop'])->name('stop'); - Route::post('/{templateCode}/reuse', [AlimtalkTemplateController::class, 'reuse'])->name('reuse'); - Route::post('/{templateCode}/cancel-approval', [AlimtalkTemplateController::class, 'cancelApproval'])->name('cancel-approval'); - Route::post('/{templateCode}/release', [AlimtalkTemplateController::class, 'release'])->name('release'); - }); }); /* diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php index ef196997..dfd47b2e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php @@ -5,7 +5,6 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\AlimtalkTemplate; use App\Models\Permission; use App\Models\Role; use App\Models\User; -use Illuminate\Http\UploadedFile; use Mockery; use Mockery\MockInterface; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; @@ -13,12 +12,12 @@ use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * 알림톡 템플릿 관리 컨트롤러 Feature 테스트. + * 알림톡 템플릿 조회 컨트롤러 Feature 테스트 (조회 전용). * * 라우트 → 컨트롤러 → 서비스 경계를 검증한다. kapi 실제 호출 로직은 * AlimtalkTemplateServiceTest 가 검증하므로, 여기서는 AlimtalkTemplateService 를 - * 컨테이너에 mock 으로 바인딩해 컨트롤러의 권한 경계(view/manage)·유형별 검증(FormRequest)· - * 응답 봉투·kapi 실패(예외)의 422 전파를 격리 검증한다. + * 컨테이너에 mock 으로 바인딩해 조회 권한 경계(view)·응답 봉투·kapi 실패(예외)의 422 + * 전파·등록 라우트 제거(405)를 격리 검증한다. * * @since 1.0.0 */ @@ -120,12 +119,48 @@ class AlimtalkTemplateControllerTest extends PluginTestCase } /** - * @scenario auth=view_only,action=store + * @scenario auth=none_of_required,action=index * - * @effects store_requires_manage_permission_returns_403 + * @effects list_requires_view_permission_returns_403 */ - public function test_view권한만으로_등록시_403(): void + public function test_view권한_없으면_목록조회_403(): void { + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.other'])) + ->getJson(self::BASE); + + $response->assertStatus(403); + } + + /** + * @scenario auth=view,service=ok + * + * @effects show_returns_template_detail_with_status_badge + */ + public function test_view권한으로_상세를_조회한다(): void + { + $this->mockService()->shouldReceive('detail')->once()->with('TW_1')->andReturn([ + 'templateCode' => 'TW_1', + 'templateName' => '주문완료', + 'status_badge' => ['variant' => 'green'], + ]); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE.'/TW_1'); + + $response->assertStatus(200); + $response->assertJsonPath('data.template.templateCode', 'TW_1'); + $response->assertJsonPath('data.template.status_badge.variant', 'green'); + } + + /** + * @scenario auth=view,action=store + * + * @effects store_route_removed_returns_405_read_only + */ + public function test_등록_라우트는_제거되어_조회전용이다(): void + { + // 조회 전용 전환으로 등록(POST)·상태변경 라우트를 제거했다. manage 미들웨어 블록이 + // 사라졌으므로 POST 는 라우트 미매칭(405)이 되어야 한다(등록은 비즈뿌리오 콘솔). $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) ->postJson(self::BASE, [ 'templateName' => 'T', @@ -134,163 +169,25 @@ class AlimtalkTemplateControllerTest extends PluginTestCase 'templateEmphasizeType' => 'NONE', ]); - $response->assertStatus(403); + $response->assertStatus(405); } /** - * @scenario auth=manage,service=ok,type=NONE - * - * @effects store_creates_template_via_service - */ - public function test_manage권한으로_기본형_등록에_성공한다(): void - { - $this->mockService()->shouldReceive('create')->once()->andReturn(['templateCode' => 'NEW']); - - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->postJson(self::BASE, [ - 'templateName' => '가입환영', - 'templateContent' => '#{name}님 환영합니다', - 'categoryCode' => '001001', - 'templateEmphasizeType' => 'NONE', - ]); - - $response->assertStatus(200); - $response->assertJsonPath('data.template.templateCode', 'NEW'); - } - - /** - * @scenario auth=manage,service=ok,requestInspection=true - * - * @effects store_with_request_inspection_flag_registers_then_requests_inspection - */ - public function test_등록후검수요청_플래그로_등록과_검수요청을_이어서_수행한다(): void - { - $service = $this->mockService(); - $service->shouldReceive('create')->once()->andReturn(['templateCode' => 'TW_NEW']); - $service->shouldReceive('requestInspection')->once()->with('TW_NEW'); - - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->postJson(self::BASE, [ - 'templateName' => '가입환영', - 'templateContent' => '#{name}님 환영합니다', - 'categoryCode' => '001001', - 'templateEmphasizeType' => 'NONE', - 'requestInspection' => true, - ]); - - $response->assertStatus(200); - $response->assertJsonPath('data.template.templateCode', 'TW_NEW'); - } - - /** - * @scenario auth=manage,type=TEXT,missing=title - * - * @effects store_validates_text_type_requires_title_returns_422 - */ - public function test_강조표기형에_강조문구_누락시_422(): void - { - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->postJson(self::BASE, [ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'TEXT', - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['templateTitle', 'templateSubtitle']); - } - - /** - * @scenario auth=manage,type=IMAGE,missing=image - * - * @effects store_validates_image_type_requires_image_fields_returns_422 - */ - public function test_이미지형에_이미지필드_누락시_422(): void - { - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->postJson(self::BASE, [ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'IMAGE', - ]); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['templateImageName', 'templateImageUrl']); - } - - /** - * @scenario auth=manage,service=throws + * @scenario auth=view,service=throws * * @effects kapi_failure_is_surfaced_as_422_with_result_code */ public function test_kapi_실패는_422로_전파된다(): void { - $this->mockService()->shouldReceive('create')->once() - ->andThrow(new BizppurioApiException('템플릿 불일치', resultCode: '7204')); + $this->mockService()->shouldReceive('list')->once() + ->andThrow(new BizppurioApiException('발신프로필을 찾을 수 없습니다.', resultCode: '7204')); - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->postJson(self::BASE, [ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - ]); + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE); $response->assertStatus(422); $response->assertJsonPath('errors.result_code', '7204'); - $response->assertJsonPath('errors.kakao_message', '템플릿 불일치'); - } - - /** - * @scenario auth=manage,action=stop - * - * @effects stop_action_delegates_to_service_stop - */ - public function test_중지_상태변경에_성공한다(): void - { - $this->mockService()->shouldReceive('stop')->once()->with('TW_1'); - - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->postJson(self::BASE.'/TW_1/stop'); - - $response->assertStatus(200); - } - - /** - * @scenario auth=manage,action=upload_image - * - * @effects image_upload_returns_kakao_url - */ - public function test_이미지_업로드가_카카오_ur_l을_반환한다(): void - { - $this->mockService()->shouldReceive('uploadImage')->once() - ->andReturn('https://mud-kage.kakao.com/dn/abc/banner.jpg'); - - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->post(self::BASE.'/image', [ - 'image' => UploadedFile::fake()->image('banner.jpg', 600, 400), - ], ['Accept' => 'application/json']); - - $response->assertStatus(200); - $response->assertJsonPath('data.image_url', 'https://mud-kage.kakao.com/dn/abc/banner.jpg'); - } - - /** - * @scenario auth=manage,action=upload_image,invalid=type - * - * @effects image_upload_rejects_non_image_returns_422 - */ - public function test_이미지가_아닌_파일_업로드는_422다(): void - { - $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) - ->post(self::BASE.'/image', [ - 'image' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'), - ], ['Accept' => 'application/json']); - - $response->assertStatus(422); - $response->assertJsonValidationErrors(['image']); + $response->assertJsonPath('errors.kakao_message', '발신프로필을 찾을 수 없습니다.'); } protected function tearDown(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php index 4ef4e27e..f04c4a83 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php @@ -11,7 +11,7 @@ use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * AlimtalkTemplateService — kapi 위임 + 상태 배지/액션 매핑 + messageType 자동계산 검증. + * AlimtalkTemplateService — kapi 조회 위임 + 상태 배지 매핑 검증 (조회 전용). */ class AlimtalkTemplateServiceTest extends PluginTestCase { @@ -30,7 +30,7 @@ class AlimtalkTemplateServiceTest extends PluginTestCase return new AlimtalkTemplateService($kakao, $pluginSettings); } - public function test_목록은_상태_배지와_가능액션을_부가한다(): void + public function test_목록은_상태_배지를_부가한다(): void { Http::fake([ 'kapi.ppurio.com/*' => Http::response([ @@ -50,7 +50,15 @@ class AlimtalkTemplateServiceTest extends PluginTestCase $tpl = $result['templates'][0]; $this->assertSame('ACT', $tpl['service_status']); $this->assertSame('green', $tpl['status_badge']['variant']); - $this->assertEqualsCanonicalizing(['stop', 'cancel_approval'], $tpl['available_actions']); + // 회귀 방지: 배지 label_key 는 프론트 lang 키 형식(templates.status.*)이어야 한다. + // 백엔드 messages.php 네임스페이스(::messages.template.status.*)로 주면 프론트 $t() 가 + // 해석하지 못해 라벨 원문이 목록/상세에 그대로 노출된다(PO 브라우저 검수로 발견된 회귀). + $this->assertSame( + 'sirsoft-message_bizppurio.templates.status.sendable', + $tpl['status_badge']['label_key'], + ); + // 조회 전용 — 상태별 가능 액션(available_actions)은 더 이상 부가하지 않는다. + $this->assertArrayNotHasKey('available_actions', $tpl); $this->assertSame(1, $result['pagination']['total']); } @@ -92,152 +100,6 @@ class AlimtalkTemplateServiceTest extends PluginTestCase $this->assertSame('green', $detail['status_badge']['variant']); } - public function test_등록은_부가정보시_message_type_e_x로_계산한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'NEW']], 200)]); - - $this->service()->create([ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - 'templateExtra' => '부가정보', - ]); - - Http::assertSent(function ($request) { - return str_contains($request->url(), '/v3/kakao/template/add') - && $request['templateMessageType'] === 'EX' - && $request['templateExtra'] === '부가정보' - && $request['senderKey'] === 'SK_40'; - }); - } - - public function test_등록은_채널추가버튼시_message_type_a_d로_계산한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); - - $this->service()->create([ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - 'buttons' => [['name' => '채널추가', 'linkType' => 'AC']], - ]); - - Http::assertSent(fn ($request) => $request['templateMessageType'] === 'AD'); - } - - public function test_등록은_부가정보와_채널추가_둘다면_m_i로_계산한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); - - $this->service()->create([ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - 'templateExtra' => '부가', - 'buttons' => [['name' => '채널추가', 'linkType' => 'AC']], - ]); - - Http::assertSent(fn ($request) => $request['templateMessageType'] === 'MI'); - } - - public function test_등록은_옵션없으면_b_a로_계산한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); - - $this->service()->create([ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - ]); - - Http::assertSent(fn ($request) => $request['templateMessageType'] === 'BA'); - } - - public function test_강조표기형은_title_subtitle을_전송한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); - - $this->service()->create([ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'TEXT', - 'templateTitle' => '강조', - 'templateSubtitle' => '보조', - ]); - - Http::assertSent(fn ($request) => $request['templateEmphasizeType'] === 'TEXT' - && $request['templateTitle'] === '강조' - && $request['templateSubtitle'] === '보조'); - } - - public function test_버튼과_바로연결과_대표링크를_전송한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]); - - $this->service()->create([ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - 'buttons' => [['name' => '웹링크', 'linkType' => 'WL', 'linkMo' => 'https://m.example.com']], - 'quickReplies' => [['name' => '바로가기', 'linkType' => 'WL']], - 'templateRepresentLink' => ['linkPc' => 'https://example.com', 'linkMo' => ''], - ]); - - Http::assertSent(function ($request) { - return $request['buttons'][0]['linkType'] === 'WL' - && $request['quickReplies'][0]['name'] === '바로가기' - // 빈 링크 필드(linkMo='')는 제거되고 linkPc 만 포함 - && $request['templateRepresentLink'] === ['linkPc' => 'https://example.com']; - }); - } - - public function test_이미지_업로드는_카카오_이미지_ur_l을_반환한다(): void - { - Http::fake([ - 'kapi.ppurio.com/v3/kakao/image/*' => Http::response([ - 'code' => '200', - 'image' => 'https://mud-kage.kakao.com/dn/abc/image.jpg', - ], 200), - ]); - - $path = tempnam(sys_get_temp_dir(), 'img'); - file_put_contents($path, 'fake-image-bytes'); - - $url = $this->service()->uploadImage($path, 'banner.jpg'); - - @unlink($path); - - $this->assertSame('https://mud-kage.kakao.com/dn/abc/image.jpg', $url); - Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/image/alimtalk/template')); - } - - public function test_검수요청은_템플릿코드와_comment를_전송한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); - - $this->service()->requestInspection('TW_1', '검토 부탁드립니다'); - - Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/request') - && $request['templateCode'] === 'TW_1' - && $request['comment'] === '검토 부탁드립니다'); - } - - public function test_중지_상태변경은_stop_엔드포인트를_호출한다(): void - { - Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]); - - $this->service()->stop('TW_1'); - - Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/stop') - && $request['templateCode'] === 'TW_1'); - } - public function test_발신프로필_키_미설정시_예외(): void { $this->expectException(BizppurioApiException::class); @@ -253,12 +115,7 @@ class AlimtalkTemplateServiceTest extends PluginTestCase ]); try { - $this->service()->create([ - 'templateName' => 'T', - 'templateContent' => '본문', - 'categoryCode' => '001', - 'templateEmphasizeType' => 'NONE', - ]); + $this->service()->list(); $this->fail('예외가 발생해야 한다.'); } catch (BizppurioApiException $e) { $this->assertSame('7204', $e->getResultCode()); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php index 795b27ad..6579b1ab 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php @@ -89,33 +89,6 @@ class BizppurioKakaoApiClientTest extends PluginTestCase && $request['templateName'] === 'T'); } - public function test_이미지_업로드는_multipart로_전송하고_ur_l을_받는다(): void - { - Http::fake([ - 'kapi.ppurio.com/v3/kakao/image/*' => Http::response([ - 'code' => '200', - 'image' => 'https://mud-kage.kakao.com/dn/xyz/img.jpg', - ], 200), - ]); - - $path = tempnam(sys_get_temp_dir(), 'img'); - file_put_contents($path, 'bytes'); - - $result = $this->client()->uploadTemplateImage($path, 'banner.jpg'); - - @unlink($path); - - $this->assertSame('https://mud-kage.kakao.com/dn/xyz/img.jpg', $result['image']); - Http::assertSent(function ($request) { - // multipart 요청은 배열 접근($request['key'])이 불가하므로 URL·메서드·multipart 여부로 검증 - $bodyData = collect($request->data())->keyBy('name'); - - return str_contains($request->url(), '/v3/kakao/image/alimtalk/template') - && $request->isMultipart() - && ($bodyData['bizId']['contents'] ?? null) === 'biz01'; - }); - } - public function test_자격증명_미설정시_예외(): void { $client = new BizppurioKakaoApiClient( diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml index 524549dc..fda48040 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml @@ -1,96 +1,81 @@ # audit:allow test-scenario-coverage reason: | -# 알림톡 템플릿 관리 화면은 설정 페이지(plugin_settings.json)의 탭으로 배치되며(⚑ 결정 2·3, -# 독립 페이지·메뉴 없음), kapi 실시간 위임이라 DB 저장이 없다. axes cross product 의 UI 구조 -# (탭 전환·목록/폼 서브뷰·유형별 필드·변수삽입·상세 모달·i18n) effects 는 Vitest -# (alimtalk_templates.test.tsx) 정적 검증으로 가드된다. 권한 경계(view/manage)·유형별 -# 조건부 검증(422)·kapi 실패 전파(422)·상태변경 위임은 PHPUnit Feature -# (AlimtalkTemplateControllerTest) 로, messageType 자동계산·상태 배지/액션 매핑·kapi 파라미터 -# 전달은 Unit(AlimtalkTemplateServiceTest) 로 Http::fake 왕복 관찰한다. 런타임 Playwright spec 은 -# 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 카카오 관리 API 자격증명 필요) 단계에서 최초 -# 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. +# 알림톡 템플릿 화면은 설정 페이지(plugin_settings.json)의 탭으로 배치되며(독립 페이지·메뉴 +# 없음), kapi 실시간 위임이라 DB 저장이 없다. 조회 전용 전환으로 등록·수정·삭제·검수·상태변경을 +# 제거하고 목록·상태·내용 조회 + 알림 연결만 남겼다(등록·관리는 비즈뿌리오 콘솔로 위임). axes +# cross product 의 UI 구조(탭 전환·목록 서브뷰·상세 모달·콘솔/준비 안내·i18n) effects 는 Vitest +# (alimtalk_templates.test.tsx) 정적 검증으로 가드된다. 조회 권한 경계(view)·등록 라우트 제거(405)· +# kapi 실패 전파(422)는 PHPUnit Feature(AlimtalkTemplateControllerTest) 로, 상태 배지 매핑·kapi +# 파라미터 전달은 Unit(AlimtalkTemplateServiceTest) 로 Http::fake 왕복 관찰한다. 런타임 Playwright +# spec 은 실연동 검증(PO 직접 수행, 카카오 관리 API 자격증명 필요) 단계에서 최초 도입한다. -feature: 알림톡 템플릿 관리 (Phase 5) +feature: 알림톡 템플릿 조회 (조회 전용) description: | 설정 페이지 알림톡 템플릿 탭에서 카카오 관리 API(kapi.ppurio.com)로 알림톡 템플릿을 - 실시간 조회·등록·수정·삭제·검수·상태변경한다. DB 저장 없이 매 요청 kapi 위임이며, 저장은 - 하지 않는다(연동 binding 은 Phase 6). 진입은 코어 소유 설정 페이지 하나로 통일(메뉴 없음). + 실시간 조회한다(목록·상태·내용). DB 저장 없이 매 요청 kapi 위임이며, 등록·수정·삭제·검수· + 상태변경은 비즈뿌리오 콘솔로 위임한다. 이 화면의 고유 가치는 카카오 템플릿 ↔ G7 알림 연결 + (binding)이며, 진입은 코어 소유 설정 페이지 하나로 통일한다(메뉴 없음). 핵심 동작: - - 탭 네비게이션: 환경설정 ↔ 알림톡 템플릿 (activeSettingsTab, 배타 전환) - - 목록 서브뷰: 상태 필터(templateStatus) + 검색(keyword) + 상태 배지(serviceStatus) + 상세 - - 폼 서브뷰(목록↔폼 전환): 유형별 필드(NONE/IMAGE/TEXT, ITEM_LIST 제외) + 변수 클릭삽입 - - templateMessageType(BA/EX/AD/MI)은 부가정보·채널추가(AC) 버튼 유무로 백엔드 자동 계산 - - 권한: 조회=messaging.view / 변경=messaging.manage - - kapi 실패(반려·차단·상태오류)는 그대로 422 로 전파(운영자가 사유 즉시 확인) + - 탭 네비게이션: 환경설정 ↔ 알림톡 템플릿 (query.tab, 배타 전환) + - 목록 서브뷰: 상태 필터(templateStatus) + 검색(keyword) + 상태 배지(serviceStatus) + [내용] + - 상세 모달: 내용만 표시 + [닫기] (관리 액션 없음) + - 권한: 조회=messaging.view (변경 라우트는 제거 — 등록은 콘솔) + - kapi 실패(발신프로필 미설정·조회 실패)는 그대로 422 로 전파(운영자가 사유 즉시 확인) - readiness: api_key·sender_key 미설정 시 안내 + [설정하기] 탭 이동 + - 안내: 목록 상단 콘솔 위임 안내 + 환경설정 탭 사용 전 준비 안내(카카오·SMS) axes: - auth: [view, manage, guest] - emphasize_type: [NONE, IMAGE, TEXT] - message_type_driver: [none, extra, add_channel, both] - save_mode: [register, register_and_request] + auth: [view, forbidden, guest] kapi_result: [ok, fail] exclusions: - - { auth: guest, emphasize_type: IMAGE, reason: "비인증은 유형 무관 401 — 유형 axis 무의미" } - - { auth: view, kapi_result: fail, reason: "view 는 변경 액션 불가(403) — kapi 도달 전 차단" } + - { auth: guest, kapi_result: fail, reason: "비인증은 결과 무관 401 — kapi 도달 전 차단" } + - { auth: forbidden, kapi_result: fail, reason: "권한 없으면 403 — kapi 도달 전 차단" } effects: # 탭 네비게이션 (UI) - tab_navigation_has_connection_and_templates_tabs - templates_tab_updates_url_query_and_refetches_list - tab_panels_toggle_exclusively_by_query_tab - - templates_tab_persists_across_reload_via_url_query - init_actions_loads_list_when_entering_templates_tab - # 데이터소스 (UI) - - templates_and_categories_datasources_target_admin_api + # 데이터소스 (UI) — 조회 전용, 카테고리 데이터소스 제거 + - templates_datasource_targets_admin_api + - category_datasource_removed_read_only + # 조회 전용 — 폼·관리 제거 (UI) + - form_view_removed + - action_modal_removed + - form_and_manage_state_absent_from_layout # 목록 서브뷰 (UI) - - list_view_shown_when_template_view_is_list - toolbar_has_status_filter_and_keyword_search + - toolbar_has_no_new_template_button_refresh_only - table_card_always_shows_header_and_branches_tbody_by_count - table_iterates_templates_with_status_badge - - new_template_button_switches_to_form_and_resets_form - # 폼 서브뷰 (UI, 계획서 §6-4) - - form_view_shown_when_template_view_is_form - - common_fields_name_and_content_use_manual_value_binding - - template_code_field_auto_or_manual_toggle - - category_two_level_dropdown_from_category_all - - text_type_fields_shown_when_emphasize_type_is_text - - image_type_file_upload_shown_when_emphasize_type_is_image - - type_buttons_expose_only_none_image_text_not_item_list - - variable_buttons_insert_into_content_state_via_insert_variable_handler - - variable_reference_table_always_visible_inline - - optional_six_items_checkbox_expand_extra_preview_button_quickreply_link_security - - optional_no_header_field - - preview_reflects_content_state - - save_split_into_register_and_register_then_request_inspection_with_help_box + - manage_column_is_single_content_button_opening_detail_modal + - list_has_row_number_requested_at_processed_at_columns + - status_badge_appends_rdy_sub_label_only_for_rdy + - top_level_actions_all_have_event_type + - pagination_updates_page_state_and_refetches + # 안내 (UI) + - list_notice_shows_badge_meaning_and_console_delegation + - settings_tab_shows_preparation_notice_kakao_and_sms # 상세 모달 & readiness (UI) - detail_modal_defined - - readiness_notice_shown_when_api_key_or_sender_key_missing + - detail_modal_shows_status_badge_and_content_close_only + - readiness_notice_shown_when_ready_flag_false_via_readiness_api # 권한 경계 (백엔드) - - list_requires_view_permission - - guest_list_returns_401 - - store_requires_manage_permission_returns_403_for_view_only - - manage_can_create_basic_template - - register_then_request_inspection_flag_creates_then_requests - # 유형별 검증 (백엔드) - - text_type_missing_title_subtitle_returns_422 - - image_type_missing_image_fields_returns_422 - # messageType 자동계산 (백엔드) - - message_type_none_computes_ba - - message_type_extra_computes_ex - - message_type_add_channel_computes_ad - - message_type_both_computes_mi - # 상태 배지/액션 & kapi 전달 (백엔드) - - service_status_maps_to_badge_and_available_actions + - list_returns_templates_with_status_badge + - show_returns_template_detail_with_status_badge + - list_requires_authentication_returns_401 + - list_requires_view_permission_returns_403 + - store_route_removed_returns_405_read_only + # 상태 배지 & kapi 전달 (백엔드) + - service_status_maps_to_badge_without_available_actions + - status_badge_label_key_uses_frontend_lang_format - detail_derives_status_from_inspection_and_status - list_passes_status_keyword_to_kapi - - text_type_sends_title_and_subtitle - - inspection_request_sends_code_and_comment - - stop_action_calls_kapi_stop_endpoint # kapi 실패 전파 (백엔드) - - kapi_failure_surfaced_as_422_with_result_code + - kapi_failure_is_surfaced_as_422_with_result_code - sender_key_missing_throws # 다국어 - all_referenced_i18n_keys_exist_in_ko_and_en @@ -101,4 +86,4 @@ test_files: - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php audit: - status: codified \ No newline at end of file + status: codified diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts index e9023699..232b2ace 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts @@ -4,7 +4,7 @@ import path from 'path'; /** * 비즈뿌리오 메시징 플러그인 프론트엔드 에셋 빌드 설정. * - * 커스텀 핸들러(insertVariable) 를 IIFE 번들로 빌드해 플러그인 활성화 시 로드한다. + * 플러그인 엔트리(resources/js/index.ts)를 IIFE 번들로 빌드해 활성화 시 로드한다. * 출력물은 dist/js/plugin.iife.js 이며 plugin.json assets.js.output 과 일치한다. */ export default defineConfig({ From eb0c72c32b00599528aa760f591e95a0644eab2a Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 22 Jul 2026 16:12:17 +0900 Subject: [PATCH 13/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=B1=84=EB=84=90=20=EB=AF=B8=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9C=EC=86=A1=20=EC=B0=A8=EB=8B=A8=20+=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EC=A0=80=EC=9E=A5=C2=B7=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자가 SMS·알림톡 채널을 꺼도 발송이 계속되던 문제 수정. - 원인: 채널 토글 저장이 map-only 라 미등록 확장 채널(sms/alimtalk)의 on/off 가 저장되지 않았고, 백엔드는 미저장 채널을 활성으로 판정 - 프론트: 토글 저장을 upsert 로 전환(미저장 채널도 저장), 하단 채널 탭을 source 기준으로 필터(확장 채널은 미저장/OFF 시 숨김) — 코어·게시판·이커머스 - 백엔드: 플러그인이 core.notification.channel_enabled 훅을 구독해 확장 채널 미저장 시 OFF 로 게이트(코어 무수정, 코어 기본 채널은 하위호환 유지) --- ...board-notification-channel-toggle.test.tsx | 45 +++++++++ .../_tab_notification_definitions.json | 5 +- ...commerceNotificationChannelToggle.test.tsx | 44 +++++++++ .../_tab_notification_definitions.json | 4 +- .../RegisterNotificationChannelsListener.php | 95 +++++++++++++++++++ .../ChannelRegistrationHookTest.php | 67 +++++++++++++ ...gisterNotificationChannelsListenerTest.php | 11 +++ ...admin-notification-channel-labels.test.tsx | 40 ++++++++ .../_tab_notification_definitions.json | 4 +- 9 files changed, 309 insertions(+), 6 deletions(-) create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx new file mode 100644 index 00000000..843dcfdf --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx @@ -0,0 +1,45 @@ +// e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 본 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재 (#28) +/** + * 게시판 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 (#28) + * + * @description + * 회귀 시나리오 (코어 admin 템플릿과 동일 구조): + * 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은 + * 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송. + * → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는 + * `.some(...) ? .map(...) : [...arr, {id...}]` 형태여야 한다. + * 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨. + * 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만 + * 노출이어야 하므로 `c.source === 'core'` 분기가 있어야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json'; + +const tabJson = JSON.stringify(tab); + +describe('게시판 알림 채널 토글 저장 upsert (#28)', () => { + it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { + // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); + // 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴 + expect( + /\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson), + ).toBe(true); + }); + + it('map-only(기존 엔트리만 변형) 저장 표현식이 남아 있지 않다', () => { + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson), 'upsert(.some) 표현식이 없으면 회귀').toBe(true); + }); +}); + +describe('게시판 하단 서브탭 필터 source 분기 (#28)', () => { + it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => { + expect(/c\.source === 'core'/.test(tabJson)).toBe(true); + expect(/is_active !== false/.test(tabJson)).toBe(true); + expect(/is_active === true/.test(tabJson)).toBe(true); + }); +}); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json index 624e4a21..9f800b06 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json @@ -1,6 +1,7 @@ { "meta": { "is_partial": true, + "_e2e_allow": "e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재 (#28)", "description": "게시판 알림 설정 탭 — 채널 토글 + 2단 탭 + 채널별 템플릿 목록" }, "id": "tab_content_notification_definitions", @@ -115,7 +116,7 @@ "handler": "setState", "params": { "target": "local", - "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}", + "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}", "hasChanges": true } } @@ -139,7 +140,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", "item_var": "ch", "index_var": "chIdx" }, diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx new file mode 100644 index 00000000..1965c9de --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx @@ -0,0 +1,44 @@ +/** + * 이커머스 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 (#28) + * + * @description + * 회귀 시나리오 (코어 admin 템플릿과 동일 구조): + * 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은 + * 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송. + * → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는 + * `.some(...) ? .map(...) : [...arr, {id...}]` 형태여야 한다. + * 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨. + * 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만 + * 노출이어야 하므로 `c.source === 'core'` 분기가 있어야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json'; + +const tabJson = JSON.stringify(tab); + +describe('이커머스 알림 채널 토글 저장 upsert (#28)', () => { + it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { + // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); + // 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴 + expect( + /\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson), + ).toBe(true); + }); + + it('map-only(기존 엔트리만 변형) 저장 표현식이 남아 있지 않다', () => { + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson), 'upsert(.some) 표현식이 없으면 회귀').toBe(true); + }); +}); + +describe('이커머스 하단 서브탭 필터 source 분기 (#28)', () => { + it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => { + expect(/c\.source === 'core'/.test(tabJson)).toBe(true); + expect(/is_active !== false/.test(tabJson)).toBe(true); + expect(/is_active === true/.test(tabJson)).toBe(true); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json index 3b5ebb93..20ee5d5e 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json @@ -115,7 +115,7 @@ "handler": "setState", "params": { "target": "local", - "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}", + "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}", "hasChanges": true } } @@ -139,7 +139,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", "item_var": "ch", "index_var": "chIdx" }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 239e94c8..85c61807 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Listeners; use App\Contracts\Extension\HookListenerInterface; +use App\Services\ModuleSettingsService; use App\Services\PluginSettingsService; +use App\Services\SettingsService; /** * 비즈뿌리오 문자·알림톡 채널을 코어 알림 시스템에 등록하는 필터 리스너. @@ -70,6 +72,11 @@ class RegisterNotificationChannelsListener implements HookListenerInterface 'priority' => 20, 'type' => 'filter', ], + 'core.notification.channel_enabled' => [ + 'method' => 'gateChannelEnabled', + 'priority' => 20, + 'type' => 'filter', + ], ]; foreach (self::CHANNEL_HOOK_PREFIXES as $prefix) { @@ -144,6 +151,94 @@ class RegisterNotificationChannelsListener implements HookListenerInterface }; } + /** + * 우리 채널(sms/alimtalk)의 "미저장=비활성(OFF)" 정책을 적용합니다 + * (core.notification.channel_enabled 필터). + * + * 코어의 기본 판정은 "채널 설정 엔트리가 없으면 활성(true)" 이다(하위호환). 하지만 + * sms·alimtalk 은 플러그인이 나중에 주입한 채널이라, 관리자가 명시적으로 켜기 전에는 + * 발송·기록하지 않아야 한다(opt-in). 게스트 발송 정책 isChannelGuestAllowed 의 + * "확장 채널은 미선언=차단" 과 같은 방향이다. + * + * 따라서 우리 채널이면서 해당 확장의 notifications.channels 저장소에 엔트리가 없을 때만 + * false 로 덮어쓴다. 엔트리가 저장되어 있으면(켜짐/꺼짐 모두) 코어가 이미 그 값을 반영해 + * $enabled 로 넘겨주므로 그대로 통과시킨다. 우리 채널이 아니면 항상 원본을 통과시킨다. + * + * @param bool $enabled 코어가 계산한 활성 여부 + * @param string $extensionType 확장 타입 (core/module/plugin) + * @param string|null $extensionIdentifier 확장 식별자 + * @param string $channelId 채널 식별자 + * @return bool 최종 활성 여부 + */ + public function gateChannelEnabled( + bool $enabled, + string $extensionType, + ?string $extensionIdentifier, + string $channelId + ): bool { + if (! in_array($channelId, self::CHANNEL_IDS, true)) { + return $enabled; + } + + // 저장 엔트리가 있으면 코어 판정($enabled)을 존중, 없으면 미저장 → OFF + if ($this->hasSavedChannelEntry($extensionType, $extensionIdentifier, $channelId)) { + return $enabled; + } + + return false; + } + + /** + * 해당 확장의 notifications.channels 저장소에 특정 채널 엔트리가 존재하는지 확인합니다. + * + * 저장소는 확장 타입별로 분리됩니다: + * - core → SettingsService + * - module → ModuleSettingsService + * - plugin → PluginSettingsService + * + * 조회 실패(예외)나 미지원 타입은 "미저장"(false)으로 간주해 안전측(OFF)으로 처리한다. + * + * @param string $extensionType 확장 타입 + * @param string|null $extensionIdentifier 확장 식별자 + * @param string $channelId 채널 식별자 + * @return bool 저장 엔트리 존재 여부 + */ + private function hasSavedChannelEntry( + string $extensionType, + ?string $extensionIdentifier, + string $channelId + ): bool { + try { + $channels = match ($extensionType) { + 'core' => app(SettingsService::class) + ->getSetting('notifications.channels', []), + 'module' => empty($extensionIdentifier) + ? [] + : app(ModuleSettingsService::class) + ->get($extensionIdentifier, 'notifications.channels', []), + 'plugin' => empty($extensionIdentifier) + ? [] + : app(PluginSettingsService::class) + ->get($extensionIdentifier, 'notifications.channels', []), + default => [], + }; + } catch (\Throwable $e) { + return false; + } + + if (! is_array($channels)) { + return false; + } + + foreach ($channels as $entry) { + if (is_array($entry) && ($entry['id'] ?? null) === $channelId) { + return true; + } + } + + return false; + } + /** * 레거시 다채널 자동 결정 경로에서 채널 후보에 sms·alimtalk 을 더합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php index 04a7c231..c104b193 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php @@ -6,6 +6,7 @@ use App\Extension\HookManager; use App\Services\ChannelReadinessService; use App\Services\NotificationChannelService; use App\Services\PluginSettingsService; +use App\Services\SettingsService; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -48,6 +49,26 @@ class ChannelRegistrationHookTest extends PluginTestCase fn ($result, $channelId) => $listener->checkReadiness($result, $channelId), 20, ); + HookManager::addFilter( + 'core.notification.channel_enabled', + fn ($enabled, $extType, $extId, $channelId) => $listener->gateChannelEnabled($enabled, $extType, $extId, $channelId), + 20, + ); + } + + /** + * 코어 SettingsService 의 notifications.channels 저장값을 지정합니다. + * + * @param array> $channels + */ + private function setCoreChannels(array $channels): void + { + $settings = \Mockery::mock(SettingsService::class); + $settings->shouldReceive('getSetting') + ->with('notifications.channels', []) + ->andReturn($channels); + $this->app->instance(SettingsService::class, $settings); + app(NotificationChannelService::class)->clearChannelEnabledCache(); } public function test_코어_채널서비스가_sms_alimtalk을_노출한다(): void @@ -94,4 +115,50 @@ class ChannelRegistrationHookTest extends PluginTestCase $this->assertTrue($result['ready']); } + + public function test_channel_enabled_미저장_sms는_off로_덮인다(): void + { + $this->registerListener(); + // mail/database 만 저장, sms/alimtalk 엔트리 없음(미저장) + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'database', 'is_active' => true], + ]); + + $service = app(NotificationChannelService::class); + + // 우리 채널: 미저장 → OFF (opt-in) + $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'sms')); + $service->clearChannelEnabledCache(); + $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'alimtalk')); + // 코어 기본 채널: 미저장이어도 ON 유지(하위호환) + $service->clearChannelEnabledCache(); + $this->assertTrue($service->isChannelEnabledForExtension('core', 'core', 'mail')); + } + + public function test_channel_enabled_저장된_on_sms는_on이다(): void + { + $this->registerListener(); + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'sms', 'is_active' => true], + ]); + + $this->assertTrue( + app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms') + ); + } + + public function test_channel_enabled_저장된_off_sms는_off이다(): void + { + $this->registerListener(); + $this->setCoreChannels([ + ['id' => 'mail', 'is_active' => true], + ['id' => 'sms', 'is_active' => false], + ]); + + $this->assertFalse( + app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms') + ); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php index 4e31bd6f..eb6d8a7a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php @@ -204,6 +204,7 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase $this->assertArrayHasKey('core.notification.filter_available_channels', $hooks); $this->assertArrayHasKey('core.notification.channel_readiness', $hooks); + $this->assertArrayHasKey('core.notification.channel_enabled', $hooks); $this->assertArrayHasKey('core.auth.notification.channels', $hooks); $this->assertArrayHasKey('sirsoft-ecommerce.notification.channels', $hooks); $this->assertArrayHasKey('sirsoft-board.notification.channels', $hooks); @@ -213,4 +214,14 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase $this->assertSame('filter', $config['type']); } } + + public function test_channel_enabled_우리채널이_아니면_원본을_통과시킨다(): void + { + $listener = $this->makeListener(); + + // mail/database 등 타 채널은 저장소 조회 없이 코어 판정($enabled)을 그대로 반환 + $this->assertTrue($listener->gateChannelEnabled(true, 'core', 'core', 'mail')); + $this->assertFalse($listener->gateChannelEnabled(false, 'core', 'core', 'database')); + $this->assertTrue($listener->gateChannelEnabled(true, 'module', 'sirsoft-board', 'slack')); + } } diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx index a99df300..da015400 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx @@ -82,3 +82,43 @@ describe('알림 채널 라벨 — registry payload string contract 회귀 가 expect(stringContractBindings.length).toBeGreaterThan(0); }); }); + +/** + * 채널 토글 저장 + 하단 서브탭 필터 회귀 가드 (#28) + * + * 회귀 시나리오: + * 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은 + * 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송. + * → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는 `.some(...) ? .map(...) : [...arr, {id...}]` + * 형태여야 한다. + * 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨 + * (사이트내 알림처럼 사라지지 않음). 코어 기본 채널(source==='core')은 미저장 시 노출 유지, + * 확장 채널은 미저장 시 숨김이어야 하므로 `c.source === 'core'` 분기가 있어야 한다. + */ +describe('채널 토글 저장 upsert + 하단 서브탭 필터 (#28)', () => { + // 표현식은 text 뿐 아니라 setState params 키 값 / iteration.source 등에도 있으므로 + // 레이아웃 전체를 직렬화해 표현식 문자열을 검사한다. + const tabJson = JSON.stringify(notifTab); + + it('채널 토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { + // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 + expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); + // 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴 + expect(/\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson)).toBe( + true, + ); + }); + + it('토글 저장이 map-only(기존 엔트리만 변형) 형태가 아니다', () => { + // 옛 표현식: {{(...channels ?? []).map(...)}} 단독. some/spread-add 가 없으면 회귀. + const hasUpsert = /\.some\(c => c\.id === ch\.id\)/.test(tabJson); + expect(hasUpsert, 'map-only 저장이면 upsert(.some) 표현식이 없다 → 회귀').toBe(true); + }); + + it('하단 서브탭 필터가 코어기본/확장 채널을 source 로 분기한다', () => { + // 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만 노출 + expect(/c\.source === 'core'/.test(tabJson)).toBe(true); + expect(/is_active !== false/.test(tabJson)).toBe(true); + expect(/is_active === true/.test(tabJson)).toBe(true); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json index 9b77bf77..22449485 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_notification_definitions.json @@ -115,7 +115,7 @@ "handler": "setState", "params": { "target": "local", - "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}", + "form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}", "hasChanges": true } } @@ -139,7 +139,7 @@ "type": "basic", "name": "Button", "iteration": { - "source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}", + "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}", "item_var": "ch", "index_var": "chIdx" }, From cd11fc60cece6669b37965bc8ea894204832c72b Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 22 Jul 2026 18:03:28 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat(auth):=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=9C=20=ED=9C=B4=EB=8C=80=ED=8F=B0=C2=B7?= =?UTF-8?q?=EC=A0=84=ED=99=94=EB=B2=88=ED=98=B8=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 마이페이지·관리자 회원 폼에는 이미 연락처 필드가 있으나 회원가입 폼에만 빠져 두 화면이 불일치했다. DB·모델·Resource는 기존 존재하므로 회원가입 경로에만 검증·저장·입력 UI를 연결한다. 연락처는 선택 항목이다. --- CHANGELOG.md | 2 + app/Http/Requests/Auth/RegisterRequest.php | 13 +- app/Services/AuthService.php | 2 + docs/backend/api/auth.md | 2 + lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 1 + .../g7-core-ja/backend/ja/validation.php | 8 ++ .../g7-template-sirsoft-basic-ja/CHANGELOG.md | 6 + .../frontend/partial/auth.json | 6 +- .../language-pack.json | 2 +- lang/en/validation.php | 8 ++ lang/ko/validation.php | 8 ++ templates/_bundled/sirsoft-basic/CHANGELOG.md | 4 + .../auth-register-contact-fields.test.tsx | 118 ++++++++++++++++++ .../sirsoft-basic/lang/partial/en/auth.json | 4 + .../sirsoft-basic/lang/partial/ko/auth.json | 4 + .../layouts/partials/auth/_register_form.json | 74 ++++++++++- .../Api/Auth/UserAuthControllerTest.php | 70 +++++++++++ .../auth-register-contact-fields.yaml | 37 ++++++ 18 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/auth-register-contact-fields.test.tsx create mode 100644 tests/scenarios/auth-register-contact-fields.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 7362e3d9..d40dc639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Added +- 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다. + #### 사이트맵 - 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.) diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php index 31e8e6bb..fa6face9 100644 --- a/app/Http/Requests/Auth/RegisterRequest.php +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Auth; use App\Extension\HookManager; use App\Models\User; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -11,6 +12,8 @@ class RegisterRequest extends FormRequest { /** * Determine if the user is authorized to make this request. + * + * @return bool 항상 true (권한은 미들웨어 체인에서 처리) */ public function authorize(): bool { @@ -20,7 +23,7 @@ class RegisterRequest extends FormRequest /** * Get the validation rules that apply to the request. * - * @return array|string> + * @return array|string> */ public function rules(): array { @@ -29,7 +32,9 @@ class RegisterRequest extends FormRequest 'nickname' => 'nullable|string|max:50', 'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)], 'password' => 'required|string|min:8|confirmed', - 'language' => 'nullable|string|in:' . implode(',', config('app.supported_locales', ['ko', 'en'])), + 'mobile' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], + 'phone' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'], + 'language' => 'nullable|string|in:'.implode(',', config('app.supported_locales', ['ko', 'en'])), 'agree_terms' => 'accepted', 'agree_privacy' => 'accepted', ]; @@ -55,6 +60,10 @@ class RegisterRequest extends FormRequest 'password.required' => __('validation.auth.password.required'), 'password.min' => __('validation.auth.password.min'), 'password.confirmed' => __('validation.auth.password.confirmed'), + 'mobile.max' => __('validation.auth.mobile.max'), + 'mobile.regex' => __('validation.auth.mobile.regex'), + 'phone.max' => __('validation.auth.phone.max'), + 'phone.regex' => __('validation.auth.phone.regex'), 'agree_terms.accepted' => __('validation.auth.agree_terms.accepted'), 'agree_privacy.accepted' => __('validation.auth.agree_privacy.accepted'), ]; diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 7f4edcb0..0bedfaed 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -196,6 +196,8 @@ class AuthService 'nickname' => $data['nickname'] ?? null, 'email' => $data['email'], 'password' => Hash::make($data['password']), + 'mobile' => $data['mobile'] ?? null, + 'phone' => $data['phone'] ?? null, 'language' => $data['language'] ?? 'ko', 'country' => $this->detectCountryFromAcceptLanguage(), 'ip_address' => request()->ip(), diff --git a/docs/backend/api/auth.md b/docs/backend/api/auth.md index 8c2880f2..9d824d43 100644 --- a/docs/backend/api/auth.md +++ b/docs/backend/api/auth.md @@ -1501,6 +1501,8 @@ HTTP/1.1 200 | nickname | body | string | 아니오 | max 50 | 닉네임 | | email | body | string | 예 | max 255 | 이메일 주소 | | password | body | string | 예 | min 8 | 비밀번호 | +| mobile | body | string | 아니오 | max 20, `숫자 - + ( ) 공백` | 휴대폰번호 (선택) | +| phone | body | string | 아니오 | max 20, `숫자 - + ( ) 공백` | 전화번호 (선택) | | language | body | string | 아니오 | `ko`, `en`, `fr`, `ja` | 언어 코드 | | agree_terms | body | string | 아니오 | — | 이용약관 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) | | agree_privacy | body | string | 아니오 | — | 개인정보 처리방침 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) | diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 2539ca68..dc72c475 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -11,6 +11,7 @@ - 확장 식별자·메뉴 역할·활동 로그 검색 조건의 안내 문구 일본어 번역 추가 — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 모듈·플러그인 파일 업로드 시 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가. - 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. ## [1.0.6] - 2026-07-22 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index 2482b506..e7c1a759 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -721,6 +721,14 @@ return [ 'required' => 'トークンは必須です。', 'string' => 'トークンは文字列である必要があります。', ], + 'mobile' => [ + 'max' => '携帯電話番号は :max文字を超えることはできません。', + 'regex' => '携帯電話番号は数字と -, +, (), スペースのみ入力できます。', + ], + 'phone' => [ + 'max' => '電話番号は :max文字を超えることはできません。', + 'regex' => '電話番号は数字と -, +, (), スペースのみ入力できます。', + ], ], 'asset' => [ 'identifier' => [ diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md index f3df9b44..8cf6397f 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-22 + +### Added + +- 회원가입 화면의 휴대폰번호·전화번호 입력란 라벨·안내 문구(`auth.mobile`, `auth.phone`) 일본어 번역 추가 — 회원가입 시 연락처 입력란이 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.1] - 2026-07-08 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/auth.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/auth.json index f49f6f52..26f6ab6b 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/auth.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/auth.json @@ -146,5 +146,9 @@ "divider": "または", "continue": "非会員として注文する", "hint": "会員登録なしで注文できます。" - } + }, + "mobile": "携帯電話番号", + "mobile_placeholder": "携帯電話番号を入力してください (選択)", + "phone": "電話番号", + "phone_placeholder": "電話番号を入力してください (選択)" } diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json index c79774c2..66c77bb3 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-basic", diff --git a/lang/en/validation.php b/lang/en/validation.php index ba649e4b..d9fb829e 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -781,6 +781,14 @@ return [ 'nickname' => [ 'max' => 'Nickname may not be greater than :max characters.', ], + 'mobile' => [ + 'max' => 'Mobile number may not be greater than :max characters.', + 'regex' => 'Mobile number may only contain digits and -, +, (), spaces.', + ], + 'phone' => [ + 'max' => 'Phone number may not be greater than :max characters.', + 'regex' => 'Phone number may only contain digits and -, +, (), spaces.', + ], 'agree_terms' => [ 'accepted' => 'You must agree to the Terms of Service.', ], diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 4ec88533..549fbe5f 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -782,6 +782,14 @@ return [ 'nickname' => [ 'max' => '닉네임은 :max자를 초과할 수 없습니다.', ], + 'mobile' => [ + 'max' => '휴대폰번호는 :max자를 초과할 수 없습니다.', + 'regex' => '휴대폰번호는 숫자와 -, +, (), 공백만 입력할 수 있습니다.', + ], + 'phone' => [ + 'max' => '전화번호는 :max자를 초과할 수 없습니다.', + 'regex' => '전화번호는 숫자와 -, +, (), 공백만 입력할 수 있습니다.', + ], 'agree_terms' => [ 'accepted' => '이용약관에 동의해주세요.', ], diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 51ba33b1..acfc0a2f 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -6,6 +6,10 @@ ## [1.0.4] - 2026-07-19 +### Added + +- 회원가입 화면에 휴대폰번호·전화번호 입력란을 추가했습니다. 두 항목 모두 선택 사항입니다. + ### Fixed #### 검색·봇 화면 diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/auth-register-contact-fields.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/auth-register-contact-fields.test.tsx new file mode 100644 index 00000000..600b061d --- /dev/null +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/auth-register-contact-fields.test.tsx @@ -0,0 +1,118 @@ +/** + * @file auth-register-contact-fields.test.tsx + * @description 회원가입 폼 휴대폰/전화번호 필드 정합성 회귀 테스트 + * + * 배경: 마이페이지 프로필 편집에는 휴대폰/전화 필드가 있으나 회원가입 폼에만 빠져 있어 + * 두 화면이 불일치했다. 회원가입에도 연락처(선택) 입력 필드를 추가한다. + * + * 검증 항목: + * 1. name="mobile" / name="phone" Input 노드가 각각 존재 (선택 항목이므로 required 없음) + * 2. 라벨/placeholder 가 auth 네임스페이스 다국어 키를 사용 + * 3. 각 필드에 per-field 에러 span 이 존재 (_local.errors?.mobile / phone) + * 4. 에러 필터 화이트리스트(폼 필드 vs 플러그인 기여 에러 구분)에 mobile/phone 이 포함 + * → 누락 시 mobile/phone 검증 에러가 상단 목록과 필드 하단에 이중 표시되는 회귀 방지 + * 5. 마케팅/광고 수신 동의 문구·체크박스는 코어 회원가입 폼에 들어가지 않는다 + */ + +import { describe, it, expect } from 'vitest'; + +import registerForm from '../../layouts/partials/auth/_register_form.json'; + +type Node = { + type?: string; + name?: string; + if?: string; + props?: Record; + children?: Node[]; + iteration?: Record; + text?: string; + comment?: string; +}; + +function walk(input: Node | Node[] | undefined, visit: (node: Node) => void): void { + if (!input) return; + const nodes = Array.isArray(input) ? input : [input]; + for (const node of nodes) { + visit(node); + if (Array.isArray(node.children)) walk(node.children, visit); + } +} + +function findInput(name: string): Node | undefined { + let found: Node | undefined; + walk(registerForm as Node, (node) => { + if (node.type === 'basic' && node.name === 'Input' && node.props?.name === name) { + found = node; + } + }); + return found; +} + +describe('회원가입 폼 휴대폰/전화번호 필드', () => { + describe('입력 필드 존재', () => { + it('name="mobile" Input 이 존재한다', () => { + expect(findInput('mobile')).toBeDefined(); + }); + + it('name="phone" Input 이 존재한다', () => { + expect(findInput('phone')).toBeDefined(); + }); + + it('휴대폰/전화 필드는 선택 항목이므로 required 가 아니다', () => { + expect(findInput('mobile')?.props?.required).toBeUndefined(); + expect(findInput('phone')?.props?.required).toBeUndefined(); + }); + + it('라벨/placeholder 가 auth 다국어 키를 사용한다', () => { + expect(findInput('mobile')?.props?.placeholder).toBe('$t:auth.mobile_placeholder'); + expect(findInput('phone')?.props?.placeholder).toBe('$t:auth.phone_placeholder'); + }); + }); + + describe('per-field 에러 표시', () => { + function hasErrorSpan(field: string): boolean { + let found = false; + walk(registerForm as Node, (node) => { + if ( + node.type === 'basic' && + node.name === 'Span' && + typeof node.if === 'string' && + node.if.includes(`errors?.${field}`) + ) { + found = true; + } + }); + return found; + } + + it('mobile 에러 span 이 존재한다', () => { + expect(hasErrorSpan('mobile')).toBe(true); + }); + + it('phone 에러 span 이 존재한다', () => { + expect(hasErrorSpan('phone')).toBe(true); + }); + }); + + describe('에러 필터 화이트리스트 (이중 표시 회귀 방지)', () => { + it('폼 필드 화이트리스트에 mobile/phone 이 포함되어 있다', () => { + const raw = JSON.stringify(registerForm); + const matches = raw.match(/\['email'[^\]]*'agree_privacy'\]/g) ?? []; + // 두 곳(상단 목록 if + iteration source) 모두에 존재해야 함 + expect(matches.length).toBeGreaterThanOrEqual(2); + for (const list of matches) { + expect(list).toContain("'mobile'"); + expect(list).toContain("'phone'"); + } + }); + }); + + describe('마케팅/광고 수신 동의 미포함 (코어 중립성)', () => { + it('코어 회원가입 폼에 마케팅/광고 수신 관련 문구가 없다', () => { + const raw = JSON.stringify(registerForm); + expect(raw).not.toContain('marketing'); + expect(raw).not.toContain('광고'); + expect(raw).not.toContain('마케팅'); + }); + }); +}); diff --git a/templates/_bundled/sirsoft-basic/lang/partial/en/auth.json b/templates/_bundled/sirsoft-basic/lang/partial/en/auth.json index ab6e20dc..9b4687d0 100644 --- a/templates/_bundled/sirsoft-basic/lang/partial/en/auth.json +++ b/templates/_bundled/sirsoft-basic/lang/partial/en/auth.json @@ -20,6 +20,10 @@ "name_placeholder": "Enter your name", "nickname": "Nickname", "nickname_placeholder": "Enter your nickname (optional)", + "mobile": "Mobile", + "mobile_placeholder": "Enter your mobile number (optional)", + "phone": "Phone", + "phone_placeholder": "Enter your phone number (optional)", "language": "Language", "language_select": "Select language", "language_ko": "한국어", diff --git a/templates/_bundled/sirsoft-basic/lang/partial/ko/auth.json b/templates/_bundled/sirsoft-basic/lang/partial/ko/auth.json index f43a0d8a..e27eb8e2 100644 --- a/templates/_bundled/sirsoft-basic/lang/partial/ko/auth.json +++ b/templates/_bundled/sirsoft-basic/lang/partial/ko/auth.json @@ -20,6 +20,10 @@ "name_placeholder": "이름을 입력하세요", "nickname": "닉네임", "nickname_placeholder": "닉네임을 입력하세요 (선택)", + "mobile": "휴대폰번호", + "mobile_placeholder": "휴대폰번호를 입력하세요 (선택)", + "phone": "전화번호", + "phone_placeholder": "전화번호를 입력하세요 (선택)", "language": "언어", "language_select": "언어 선택", "language_ko": "한국어", diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/auth/_register_form.json b/templates/_bundled/sirsoft-basic/layouts/partials/auth/_register_form.json index 6c9bd7ee..f08c5871 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/auth/_register_form.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/auth/_register_form.json @@ -68,12 +68,12 @@ "comment": "비-form-field 에러 메시지 (identity / verification_token 등 plugin 기여 에러)를 추가 안내", "type": "basic", "name": "Ul", - "if": "{{_local.errors && Object.keys(_local.errors).filter(function(k) { return ['email','password','password_confirmation','name','nickname','language','agree_terms','agree_privacy'].indexOf(k) === -1; }).length > 0}}", + "if": "{{_local.errors && Object.keys(_local.errors).filter(function(k) { return ['email','password','password_confirmation','name','nickname','mobile','phone','language','agree_terms','agree_privacy'].indexOf(k) === -1; }).length > 0}}", "props": { "className": "mt-2 list-disc list-inside space-y-1" }, "iteration": { - "source": "Object.entries(_local.errors ?? {}).filter(function(entry) { return ['email','password','password_confirmation','name','nickname','language','agree_terms','agree_privacy'].indexOf(entry[0]) === -1; }).flatMap(function(entry) { return Array.isArray(entry[1]) ? entry[1] : [entry[1]]; })", + "source": "Object.entries(_local.errors ?? {}).filter(function(entry) { return ['email','password','password_confirmation','name','nickname','mobile','phone','language','agree_terms','agree_privacy'].indexOf(entry[0]) === -1; }).flatMap(function(entry) { return Array.isArray(entry[1]) ? entry[1] : [entry[1]]; })", "item_var": "message" }, "children": [ @@ -314,6 +314,76 @@ } ] }, + { + "comment": "휴대폰번호 필드 (선택)", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" + }, + "text": "$t:auth.mobile" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "tel", + "name": "mobile", + "placeholder": "$t:auth.mobile_placeholder", + "className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500", + "disabled": "{{_local?.isRegistering}}" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.mobile}}", + "props": { + "className": "text-red-500 text-xs mt-1" + }, + "text": "{{_local.errors?.mobile?.[0] ?? ''}}" + } + ] + }, + { + "comment": "전화번호 필드 (선택)", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" + }, + "text": "$t:auth.phone" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "tel", + "name": "phone", + "placeholder": "$t:auth.phone_placeholder", + "className": "w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-blue-500", + "disabled": "{{_local?.isRegistering}}" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.phone}}", + "props": { + "className": "text-red-500 text-xs mt-1" + }, + "text": "{{_local.errors?.phone?.[0] ?? ''}}" + } + ] + }, { "comment": "언어 설정 필드", "type": "basic", diff --git a/tests/Feature/Api/Auth/UserAuthControllerTest.php b/tests/Feature/Api/Auth/UserAuthControllerTest.php index bff3edd8..6ebe938e 100644 --- a/tests/Feature/Api/Auth/UserAuthControllerTest.php +++ b/tests/Feature/Api/Auth/UserAuthControllerTest.php @@ -403,6 +403,76 @@ class UserAuthControllerTest extends TestCase $this->assertEquals('en', $user->language); } + /** + * 회원가입 시 휴대폰/전화번호가 저장되는지 확인 + */ + public function test_register_stores_mobile_and_phone(): void + { + $response = $this->jsonRequest()->postJson('/api/auth/register', [ + 'name' => '테스트 사용자', + 'email' => 'contact@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'mobile' => '010-1234-5678', + 'phone' => '02-123-4567', + 'agree_terms' => true, + 'agree_privacy' => true, + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('users', [ + 'email' => 'contact@example.com', + 'mobile' => '010-1234-5678', + 'phone' => '02-123-4567', + ]); + } + + /** + * 휴대폰/전화번호는 선택 항목이므로 미입력 시에도 가입에 성공한다 + */ + public function test_register_mobile_and_phone_are_optional(): void + { + $response = $this->jsonRequest()->postJson('/api/auth/register', [ + 'name' => '테스트 사용자', + 'email' => 'nocontact@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'agree_terms' => true, + 'agree_privacy' => true, + ]); + + $response->assertStatus(201); + + $user = User::where('email', 'nocontact@example.com')->first(); + $this->assertNotNull($user); + $this->assertNull($user->mobile); + $this->assertNull($user->phone); + } + + /** + * 휴대폰번호 형식 위반 시 검증 실패(422) + */ + public function test_register_validates_mobile_format(): void + { + $response = $this->jsonRequest()->postJson('/api/auth/register', [ + 'name' => '테스트 사용자', + 'email' => 'badmobile@example.com', + 'password' => 'password123', + 'password_confirmation' => 'password123', + 'mobile' => '휴대폰없음', + 'agree_terms' => true, + 'agree_privacy' => true, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['mobile']); + + $this->assertDatabaseMissing('users', [ + 'email' => 'badmobile@example.com', + ]); + } + /** * 회원가입 시 'user' 역할 자동 할당 */ diff --git a/tests/scenarios/auth-register-contact-fields.yaml b/tests/scenarios/auth-register-contact-fields.yaml new file mode 100644 index 00000000..b01ea189 --- /dev/null +++ b/tests/scenarios/auth-register-contact-fields.yaml @@ -0,0 +1,37 @@ +# audit:allow test-scenario-coverage reason: 본 매니페스트는 신규 표면(회원가입 연락처 필드) 시나리오 SSoT 로 기록. 핵심 회귀 가드는 test_files 의 통과 테스트(백엔드 3 + 레이아웃 렌더링 8)로 커버. 선택 입력 필드 2개 추가라 발행/상태 전이 등 브라우저 특유 로직이 없어 Playwright spec 대신 렌더링 테스트로 대체(위지윅 발행 회귀 #238 유형의 위험 부재). + +feature: 회원가입 시 휴대폰/전화번호 선택 입력 + +description: | + 회원가입 폼에서 연락처(휴대폰/전화번호)를 선택 항목으로 수집한다. + + 배경: + - 마이페이지 프로필 편집·관리자 회원 폼에는 이미 mobile/phone 필드가 있으나 + 회원가입 폼에만 빠져 두 화면이 불일치했다. 회원가입 경로에 "연결"만 추가. + - DB(users.mobile/phone)·모델 $fillable·UserResource 는 이미 존재. + RegisterRequest 검증 + AuthService::register $userData 주입 + 폼 입력칸만 추가. + - 순수 개인정보 수집 (선택). 마케팅/광고 수신 동의는 코어에 넣지 않으며 + 별도 마케팅 동의 플러그인이 register_extension_fields 앵커 + + core.auth.record_consents 훅으로 주입한다. + +axes: + mobile_input: [empty, valid, invalid_chars] + phone_input: [empty, valid, invalid_chars] + +exclusions: + - { mobile_input: invalid_chars, phone_input: valid, reason: "필드별 독립 검증 — 한 필드 위반 시 해당 필드 에러만 확인하면 충분" } + - { mobile_input: valid, phone_input: invalid_chars, reason: "동일 사유 (필드별 독립 regex)" } + +effects: + - empty_mobile_and_phone_still_registers_successfully + - valid_mobile_and_phone_persist_to_users_table + - invalid_mobile_chars_rejected_with_422_validation_error + - mobile_phone_are_nullable_optional_not_required + - register_form_renders_mobile_and_phone_input_nodes + - mobile_phone_per_field_error_span_present + - error_filter_whitelist_includes_mobile_phone_to_prevent_double_display + - core_register_form_excludes_marketing_consent_copy + +test_files: + - tests/Feature/Api/Auth/UserAuthControllerTest.php + - templates/_bundled/sirsoft-basic/__tests__/layouts/auth-register-contact-fields.test.tsx \ No newline at end of file From 8aa395c3a133dc2a902ba89c9ef4fb5f744cdba8 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Thu, 23 Jul 2026 14:26:31 +0900 Subject: [PATCH 15/34] =?UTF-8?q?fix(notification):=20=EB=B9=84=ED=99=9C?= =?UTF-8?q?=EC=84=B1=20=ED=99=95=EC=9E=A5=EC=9D=B4=20=EC=A0=9C=EA=B3=B5?= =?UTF-8?q?=ED=95=98=EB=8D=98=20=EC=95=8C=EB=A6=BC=20=EC=B1=84=EB=84=90?= =?UTF-8?q?=EC=9D=84=20=EB=B0=9C=EC=86=A1=20=EB=8C=80=EC=83=81=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비활성 상태의 확장이 등록했던 발송 수단(문자·알림톡 등)을 코어 via가 저장된 채널 설정만 보고 계속 발송·"건너뜀" 로깅하던 문제를 수정한다. 사용 가능한 채널 목록에 없는 채널은 로그 없이 조용히 제외하며, 알림 설정·템플릿은 그대로 유지되어 확장을 다시 켜면 복구된다. 아울러 발송이 타임아웃·연결실패로 최종 실패할 때 pending 으로 방치되던 이력을 failed 로 마감한다(webhook 이 먼저 확정한 건은 덮어쓰지 않음). --- CHANGELOG.md | 1 + app/Notifications/GenericNotification.php | 39 ++++++++++++ .../src/Jobs/SendMessageJob.php | 29 +++++++-- .../tests/Unit/Jobs/SendMessageJobTest.php | 50 ++++++++++++++++ .../Unit/Services/SmsChannelDriverTest.php | 13 ++++ .../GenericNotificationViaTest.php | 59 ++++++++++++++++++- 6 files changed, 186 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d40dc639..ebd9dbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ - 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다. - 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다. +- 문자·알림톡처럼 확장 기능이 추가하는 알림 발송 수단을 그 확장을 끈 뒤에도 발송 대상으로 삼아, 발송 이력에 "건너뜀"으로 남던 문제를 수정했습니다. 이제 확장을 끄면 해당 발송 수단은 발송 대상에서 완전히 제외되어 이력에도 남지 않고, 다시 켜면 알림 설정 그대로 복구됩니다. ## [7.0.5] - 2026-07-16 diff --git a/app/Notifications/GenericNotification.php b/app/Notifications/GenericNotification.php index b16d5c33..d21be8f4 100644 --- a/app/Notifications/GenericNotification.php +++ b/app/Notifications/GenericNotification.php @@ -67,6 +67,14 @@ class GenericNotification extends BaseNotification // 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인 if ($this->channel !== null) { try { + // 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음). + // 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만, + // 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지 + // 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님). + if (! $this->isChannelAvailable($this->channel)) { + return []; + } + // 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외 if ($this->isChannelBlockedForGuest($notifiable, $this->channel)) { $this->logSkippedChannels([ @@ -126,6 +134,12 @@ class GenericNotification extends BaseNotification $skippedChannels = []; foreach ($channels as $channel) { + // 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이 + // 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책). + if (! $this->isChannelAvailable($channel)) { + continue; + } + // 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외 if ($this->isChannelBlockedForGuest($notifiable, $channel)) { $skippedChannels[] = [ @@ -194,6 +208,31 @@ class GenericNotification extends BaseNotification } } + /** + * 채널이 현재 사용 가능한(살아 있는) 채널 목록에 있는지 확인합니다. + * + * getAvailableChannels() 는 코어 기본 채널(mail/database)과 활성 확장이 filter 훅으로 추가한 + * 채널만 담는다. 확장이 비활성/삭제되면 그 채널이 이 목록에서 자동으로 빠지므로, 특정 확장 + * 이름을 알지 못해도 "죽은 채널"을 판별할 수 있다. 조회 실패 시(부팅 초기 등) 안전측으로 true + * 를 반환해 기존 게이트(확장 토글·readiness)가 판정하도록 위임한다. + * + * @param string $channel 채널 식별자 + * @return bool 사용 가능하면 true + */ + private function isChannelAvailable(string $channel): bool + { + try { + $availableIds = array_column( + app(NotificationChannelService::class)->getAvailableChannels(), + 'id' + ); + + return in_array($channel, $availableIds, true); + } catch (\Throwable $e) { + return true; + } + } + /** * 비회원 수신자에게 해당 채널 발송이 차단되는지 확인합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php index c039f494..ac24d448 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -130,18 +130,39 @@ class SendMessageJob implements ShouldQueue } /** - * 모든 재시도 소진 후 최종 실패 시 로그를 남깁니다. + * 모든 재시도 소진 후 최종 실패 시 로그를 남기고 발송 이력을 실패로 마감합니다. + * + * 타임아웃·연결실패(ConnectionException)는 결과코드 없는 예외로 handle() 의 상태 갱신 + * 분기를 우회하므로, 재시도 소진 후 이력이 pending 인 채 방치된다. 여기서 refkey 로 이력을 + * 조회해 failed 로 마감한다(전송 실패라 최종 실패는 webhook 으로도 회수 불가). 이미 확정 + * (success/failed)된 이력은 webhook 이 먼저 결과를 확정한 경우이므로 덮어쓰지 않는다(멱등). * * @param Throwable $exception 마지막으로 발생한 예외 */ public function failed(Throwable $exception): void { + $resultCode = $exception instanceof BizppurioApiException + ? $exception->getResultCode() + : null; + Log::error('비즈뿌리오 발송 Job 최종 실패', [ 'refkey' => $this->refkey, 'error' => $exception->getMessage(), - 'result_code' => $exception instanceof BizppurioApiException - ? $exception->getResultCode() - : null, + 'result_code' => $resultCode, + ]); + + $dispatches = app(BizppurioDispatchRepositoryInterface::class); + $dispatch = $dispatches->findByRefkey($this->refkey); + + // 이력 없음(비정상) 또는 이미 확정(webhook 선반영) → 마감 불필요(멱등) + if ($dispatch === null || $dispatch->status->isFinal()) { + return; + } + + $dispatches->update($dispatch, [ + 'status' => DispatchStatus::Failed->value, + 'result_code' => $resultCode, + 'result_message' => $exception->getMessage(), ]); } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php index 46dbe4d9..d97e6693 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -2,6 +2,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Jobs; +use Illuminate\Http\Client\ConnectionException; use Mockery; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; @@ -113,6 +114,55 @@ class SendMessageJobTest extends PluginTestCase $this->assertTrue($job->afterCommit); } + public function test_최종실패_콜백은_pending이력을_failed로_마감하고_사유를_기록한다(): void + { + // 타임아웃·연결실패는 결과코드 없는 ConnectionException 으로 재시도 소진 후 failed() 로 온다. + $dispatch = $this->seedPending('ref1'); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->failed(new ConnectionException('cURL error 28: Connection timed out')); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertNull($dispatch->result_code); // 전송 실패라 결과코드 없음 + $this->assertStringContainsString('timed out', (string) $dispatch->result_message); + } + + public function test_최종실패_콜백은_bizppurio_api_exception의_결과코드를_보존한다(): void + { + $dispatch = $this->seedPending('ref1'); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->failed(new BizppurioApiException('일시 오류', resultCode: '5003')); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('5003', $dispatch->result_code); + } + + public function test_이미_확정된_이력은_최종실패_콜백이_덮어쓰지_않는다(): void + { + // webhook 이 먼저 success 로 확정한 뒤 failed() 가 늦게 불려도 멱등해야 한다. + $dispatch = $this->seedPending('ref1'); + $dispatch->update(['status' => DispatchStatus::Success->value, 'result_code' => '4000']); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->failed(new ConnectionException('timed out')); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4000', $dispatch->result_code); + } + + public function test_이력이_없으면_최종실패_콜백은_아무것도_하지_않는다(): void + { + // 이력 seed 없이 호출 — 예외 없이 조용히 종료해야 한다. + $job = new SendMessageJob($this->payload, 'missing-ref'); + $job->failed(new ConnectionException('timed out')); + + $this->assertDatabaseMissing('bizppurio_dispatches', ['refkey' => 'missing-ref']); + } + protected function tearDown(): void { Mockery::close(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index bc222347..407ba99e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -223,4 +223,17 @@ class SmsChannelDriverTest extends PluginTestCase Bus::assertNotDispatched(SendMessageJob::class); } + + public function test_정상_조건에서_발송하고_이력을_생성한다(): void + { + // 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다. + // 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다. + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate())->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 1); + } } diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php index d3822835..d544af73 100644 --- a/tests/Unit/Notifications/GenericNotificationViaTest.php +++ b/tests/Unit/Notifications/GenericNotificationViaTest.php @@ -164,15 +164,23 @@ class GenericNotificationViaTest extends TestCase /** * NotificationChannelService::isChannelEnabledForExtension()의 응답을 mocking. * + * getAvailableChannels() 는 기본적으로 mail/database 만 반환하도록 mock 한다 + * (비활성 확장 채널 제외 회귀 테스트가 available 목록을 재정의할 수 있도록). + * * @param array $channelMap + * @param array|null $availableIds available 채널 id 목록(미지정 시 mail/database) */ - private function mockChannelEnabled(array $channelMap): void + private function mockChannelEnabled(array $channelMap, ?array $availableIds = null): void { + $availableIds ??= ['mail', 'database']; + $mock = Mockery::mock(NotificationChannelService::class); $mock->shouldReceive('isChannelEnabledForExtension') ->andReturnUsing(function ($type, $identifier, $channel) use ($channelMap) { return $channelMap[$channel] ?? true; }); + $mock->shouldReceive('getAvailableChannels') + ->andReturn(array_map(static fn (string $id) => ['id' => $id], $availableIds)); $this->app->instance(NotificationChannelService::class, $mock); } @@ -300,6 +308,55 @@ class GenericNotificationViaTest extends TestCase $this->assertEmpty($channels); } + /** + * 사용 가능한 채널 목록에 없는 채널(비활성 확장이 제공하던 채널)은 via() 가 제외한다. + * + * 회귀: 비즈뿌리오 등 확장이 비활성이면 그 확장의 채널(sms/alimtalk)이 getAvailableChannels() + * 에서 빠진다. 하지만 저장된 채널 설정·템플릿은 남아 있어, via() 가 available 목록을 확인하지 + * 않으면 죽은 채널을 발송 후보로 넣고 "건너뜀" 로그까지 남긴다. available 목록에 없는 채널은 + * 로그 없이 제외되어야 한다 (채널 지정 경로). + */ + public function test_via_with_explicit_channel_excludes_channel_not_in_available_list(): void + { + // available = mail/database 만 (sms 는 비활성 확장이라 목록에 없음) + $this->mockChannelEnabled(['sms' => true], availableIds: ['mail', 'database']); + + $notification = new GenericNotification( + type: 'test_via_check', + hookPrefix: 'core.test', + data: [], + extensionType: 'core', + extensionIdentifier: 'core', + channel: 'sms' + ); + $channels = $notification->via($this->user); + + $this->assertEmpty($channels, '사용 가능 목록에 없는 sms 는 발송 후보에서 빠져야 한다.'); + // 로그도 남지 않아야 한다 (건너뜀 기록 X) + $this->assertDatabaseMissing('notification_logs', ['channel' => 'sms']); + } + + /** + * 다채널 자동 결정 경로에서도 available 목록에 없는 채널은 로그 없이 제외된다. + */ + public function test_via_multichannel_excludes_channel_not_in_available_list(): void + { + $this->createTemplate('mail'); + $this->createTemplate('database'); + + // 정의 채널에 sms 를 포함시키되 available 목록에는 없게 한다 + $this->definition->update(['channels' => ['mail', 'database', 'sms']]); + $this->mockChannelEnabled([], availableIds: ['mail', 'database']); + + $notification = new GenericNotification('test_via_check', 'core.test'); + $channels = $notification->via($this->user); + + $this->assertNotContains('sms', $channels, '사용 가능 목록에 없는 sms 는 제외되어야 한다.'); + $this->assertContains('mail', $channels); + $this->assertContains('database', $channels); + $this->assertDatabaseMissing('notification_logs', ['channel' => 'sms']); + } + /** * 테스트용 템플릿 생성 헬퍼 */ From ca5c9a34381a34ada4b81df0cb9551dba0b74472 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Thu, 23 Jul 2026 15:11:47 +0900 Subject: [PATCH 16/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=97=B0=EA=B2=B0=20=EB=AA=A8=EB=8B=AC?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EC=99=80=20'=EC=8A=B9=EC=9D=B8=20=ED=85=9C=ED=94=8C=EB=A6=BF?= =?UTF-8?q?=200=EA=B1=B4'=EC=9D=84=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자격증명 오류 등으로 카카오 템플릿 조회가 실패하면 드롭다운이 비면서 '승인된 템플릿이 없습니다'로 떠서 실제 0건과 구분되지 않던 문제를 수정한다. 데이터소스 fallback 에 load_failed 마커를 실어, 드롭다운이 비었을 때 조회 실패(설정 확인 안내)와 진짜 0건(승인 템플릿 없음)을 문구로 분기한다. 에러 토스트 재도입 없이 모달 내 인라인 문구로만 구분한다. --- .../extensions/notification_tab_core.json | 18 ++++++++-- .../layouts/notification_tab_binding.test.tsx | 35 +++++++++++++++++++ .../resources/lang/en.json | 1 + .../resources/lang/ko.json | 1 + 4 files changed, 52 insertions(+), 3 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json index e5d0b3d0..9aa02141 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -18,7 +18,7 @@ }, { "id": "bizppurioApprovedTemplates", - "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 빈 목록 사용, 안내는 배너·'승인 템플릿 없음' 문구가 담당). 이 토스트를 막지 않으면 모달 열 때마다 에러 토스트가 뜬다.", + "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용, 토스트 재도입 금지 — 모달 열 때마다 에러 토스트가 뜨던 회귀). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다 — 정상 응답에는 이 필드가 없으므로, 드롭다운이 비었을 때 load_failed 유무로 '불러오지 못함(자격증명 확인)' vs '승인 템플릿 없음'을 분기한다.", "type": "api", "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", "method": "GET", @@ -31,7 +31,8 @@ }, "fallback": { "data": { - "templates": [] + "templates": [], + "load_failed": true } } } @@ -249,9 +250,20 @@ ] }, { + "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", "type": "basic", "name": "P", - "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0}}", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" + }, + { + "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", "props": { "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx index d179f33a..fc420452 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -156,6 +156,41 @@ describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 }); }); +describe('binding UI — 드롭다운 조회 실패 vs 0건 구분(결함 3)', () => { + const modal = findById(overlayRoot, 'modal_bizppurio_binding'); + + it('승인 템플릿 데이터소스 fallback 에 load_failed:true 마커가 있다', () => { + const ds = ((overlay as { data_sources?: Array> }).data_sources ?? []) + .find((d) => d.id === 'bizppurioApprovedTemplates'); + const fallback = (ds?.fallback as { data?: Record })?.data ?? {}; + // 조회 실패 시 이 마커가 상태에 실려 '0건'과 구분된다. 정상 응답에는 이 필드가 없다. + expect(fallback.load_failed).toBe(true); + expect(Array.isArray(fallback.templates)).toBe(true); + expect((fallback.templates as unknown[]).length).toBe(0); + }); + + it('드롭다운이 비었을 때 조회 실패(load_failed)면 설정 확인 문구를 노출한다', () => { + const raw = JSON.stringify(modal); + // 조회 실패 분기: length===0 && load_failed===true → templates_load_failed + expect(raw).toContain('binding.templates_load_failed'); + expect(raw).toContain('load_failed === true'); + }); + + it('드롭다운이 비었을 때 조회 정상(0건)이면 승인 템플릿 없음 문구를 노출한다', () => { + const raw = JSON.stringify(modal); + // 0건 분기: length===0 && !load_failed → no_approved_templates + expect(raw).toContain('binding.no_approved_templates'); + expect(raw).toContain('!(bizppurioApprovedTemplates?.data?.load_failed)'); + }); + + it('두 문구는 상호배타 조건이라 동시에 뜨지 않는다(조회실패=빨강 / 0건=amber)', () => { + const raw = JSON.stringify(modal); + // 조회 실패 문구는 red, 0건 문구는 amber 로 시각 구분 + expect(raw).toContain('text-red-600'); + expect(raw).toContain('text-amber-600'); + }); +}); + describe('binding UI — 코어 무오염 + i18n', () => { it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { const all = JSON.stringify(overlay) + JSON.stringify(footer); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index 23f7a401..2ac93845 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -80,6 +80,7 @@ "connected_template": "Connected Template", "none": "Not connected", "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", + "templates_load_failed": "Failed to load the template list. Check the plugin settings (credentials).", "fallback_sms": "Fall back to SMS on failure", "fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.", "variables_hint": "Available variables (auto-substituted on send)", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index d177a645..fab7e0fb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -80,6 +80,7 @@ "connected_template": "연결 템플릿", "none": "연결 안 함", "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", + "templates_load_failed": "템플릿 목록을 불러오지 못했습니다. 플러그인 설정(자격증명)을 확인하세요.", "fallback_sms": "실패 시 SMS로 대체발송", "fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.", "variables_hint": "제공 변수 (발송 시 자동 치환)", From d4bb117b9bba76eaab023c93de28ac823720b20d Mon Sep 17 00:00:00 2001 From: chym1217 Date: Thu, 23 Jul 2026 16:46:37 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=EB=82=B4=EC=9A=A9=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=20+=20=EC=97=B0=EA=B2=B0=20=EC=86=8C?= =?UTF-8?q?=EC=8B=A4=20=EA=B0=90=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림톡 발송이 카카오 승인 템플릿의 본문·버튼·요소를 실제로 채워 보내도록 구현(B안): 카카오 상세조회 + template_code 단위 캐시(기본 60분, 수동 초기화·끄기 지원), 카카오→발송 형식 변환 + 변수 치환 - 결과코드 재시도 분류 정정(알림톡 일시오류 7306/7307/7421/7437 추가) - 발신프로필 응답 파싱 정정(data.success[]) - 연결한 카카오 템플릿 소실(삭제·차단·미승인) 시 알림 설정에 "사용 불가" 표시, 조회 실패/승인 없음 구분 안내(결함 2·3) --- .../sirsoft-message_bizppurio/CHANGELOG.md | 5 + .../docs/api/alimtalk-templates.md | 46 +++ .../lang/en/messages.php | 5 + .../lang/en/result_codes.php | 4 + .../lang/ko/messages.php | 5 + .../lang/ko/result_codes.php | 4 + .../sirsoft-message_bizppurio/plugin.php | 11 + .../extensions/notification_row_footer.json | 13 + .../layouts/alimtalk_templates.test.tsx | 29 +- .../layouts/notification_tab_binding.test.tsx | 9 + .../layouts/plugin_settings.test.tsx | 14 + .../resources/lang/en.json | 14 + .../resources/lang/ko.json | 14 + .../layouts/admin/plugin_settings.json | 201 ++++++++++++-- .../Admin/AlimtalkTemplateController.php | 21 ++ .../Admin/NotificationBindingController.php | 4 +- .../src/Jobs/SendMessageJob.php | 12 +- .../MessageBizppurioServiceProvider.php | 2 + .../src/Services/AlimtalkChannelDriver.php | 98 ++++--- .../src/Services/AlimtalkPayloadMapper.php | 261 ++++++++++++++++++ .../src/Services/AlimtalkTemplateService.php | 11 +- .../Services/KakaoTemplateContentResolver.php | 193 +++++++++++++ .../Services/NotificationBindingService.php | 70 ++++- .../src/Services/ResultCodeResolver.php | 7 +- .../src/routes/api.php | 7 + .../AlimtalkTemplateControllerTest.php | 43 +++ .../tests/Unit/Jobs/SendMessageJobTest.php | 16 ++ .../Services/AlimtalkChannelDriverTest.php | 115 ++++++-- .../Services/AlimtalkPayloadMapperTest.php | 169 ++++++++++++ .../Services/AlimtalkTemplateServiceTest.php | 27 ++ .../KakaoTemplateContentResolverTest.php | 174 ++++++++++++ .../NotificationBindingServiceTest.php | 117 +++++++- .../Unit/Services/ResultCodeResolverTest.php | 14 +- .../scenarios/alimtalk-dispatch-binding.yaml | 47 +++- 34 files changed, 1690 insertions(+), 92 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 0d028288..c8d9ac81 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -25,3 +25,8 @@ - 목록 상단에 배지 의미(발송가능·검수중·미검수·반려)와 함께, 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 한다는 안내와 콘솔 바로가기를 제공합니다. - 환경설정 탭에 카카오 알림톡·문자(SMS/LMS) 사용 전 준비 사항(비즈뿌리오 가입·API 승인·발신프로필 등록·발신번호 사전 등록)을 안내하는 박스를 추가했습니다. - 템플릿 목록·상세 조회가 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 발신프로필 미설정 등)를 화면에 그대로 표시합니다. +- 알림에 연결한 카카오 알림톡 템플릿을 실제로 발송합니다. 카카오에 등록·승인된 템플릿의 본문뿐 아니라 버튼·바로연결·강조표기·아이템리스트·대표링크까지 그대로 발송에 반영하며, 각 항목의 변수는 발송 시점의 실제 데이터로 채워집니다. 버튼이나 링크가 포함된 템플릿도 정상적으로 발송됩니다. +- 환경설정에 "알림톡 내용 캐시 시간(분)"과 "캐시 초기화" 버튼을 추가했습니다. 카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용하며(기본 60분, 0으로 두면 발송할 때마다 최신 내용을 가져옵니다), 카카오에서 템플릿을 방금 수정한 경우 [캐시 초기화]로 다음 발송부터 최신 내용이 즉시 반영되도록 할 수 있습니다. +- 알림톡 발송이 일시적인 오류(카카오 시스템 오류·처리 지연·타임아웃 등)로 실패한 경우 자동으로 다시 시도합니다. +- 알림에 연결한 알림톡 템플릿이 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요" 표시가 나타납니다. 예전에는 연결이 끊긴 뒤에도 정상 연결처럼 보여 발송 실패 후에야 알 수 있었지만, 이제 화면에서 바로 확인하고 다시 연결할 수 있습니다. (카카오 조회가 일시적으로 안 되는 경우에는 잘못된 경고가 뜨지 않도록 표시를 보류합니다.) +- 알림톡 연결 창에서 자격증명 오류 등으로 승인 템플릿 목록을 불러오지 못한 경우와, 승인된 템플릿이 실제로 없는 경우를 구분해 안내합니다. 불러오기에 실패하면 설정을 확인하라는 안내가, 실제로 없으면 템플릿을 먼저 등록·검수하라는 안내가 표시됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md index 00559669..68d8e622 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -176,3 +176,49 @@ Authorization: Bearer {YOUR_TOKEN} **설명** +### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cache.clear` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@clearCache` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +| 필드 | 타입 | 설명 | +| --- | --- | --- | +| `cleared` | integer | 초기화한 캐시 키 수(연결된 고유 알림톡 템플릿 코드 수) | + +**응답 예시** + +```json +{ + "success": true, + "message": "알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.", + "data": { "cleared": 3 } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | + +**설명** + +발송 시 알림톡은 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 카카오 상세조회로 가져와 채우며, 그 결과를 template_code 단위로 캐시한다(기본 1시간, 환경설정 `template_cache_ttl` 로 조정, 0이면 캐시 끔). 카카오 콘솔에서 템플릿 내용을 방금 변경해 캐시 만료를 기다리지 않고 즉시 반영하고 싶을 때 이 엔드포인트로 캐시를 비운다. 연결(binding)된 모든 알림톡 템플릿의 캐시를 초기화하며, 다음 발송에서 최신 내용으로 재조회된다. 카카오 API 를 호출하지 않고 로컬 캐시만 비우므로 rate limit 에 영향을 주지 않는다. 관리자 화면(알림톡 템플릿 탭)의 "내용 캐시 초기화" 버튼이 이 엔드포인트를 호출한다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 8bbde0f2..66f77757 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -81,4 +81,9 @@ return [ 'saved' => 'Alimtalk binding saved.', 'removed' => 'Alimtalk binding removed.', ], + + // Dispatch template content cache (AlimtalkTemplateController::clearCache response) + 'cache' => [ + 'cleared' => 'Alimtalk template content cache cleared. The latest content will apply from the next dispatch.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php index 7f5a6ed4..7d83d64f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -58,9 +58,13 @@ return [ '7107' => 'Blocked sender key', '7204' => 'Template mismatch', '7206' => 'Unapproved template', + '7306' => 'Kakao system error', + '7307' => 'Processing delayed', '7308' => 'Phone number error', '7320' => 'Receiver blocked', '7325' => 'Variable length exceeded', + '7421' => 'Timeout', '7436' => 'Insufficient wallet balance (Alimtalk)', + '7437' => 'Message request failed', '7523' => 'Other error', ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index 2396ce37..4144a4e9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -81,4 +81,9 @@ return [ 'saved' => '알림톡 연동을 저장했습니다.', 'removed' => '알림톡 연동을 해제했습니다.', ], + + // 발송용 템플릿 내용 캐시 (AlimtalkTemplateController::clearCache 응답) + 'cache' => [ + 'cleared' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php index cab79a08..89ec128c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -61,9 +61,13 @@ return [ '7107' => '차단된 발신 키', '7204' => '템플릿 불일치', '7206' => '검수되지 않은 템플릿', + '7306' => '카카오 시스템 오류', + '7307' => '처리 지연', '7308' => '전화번호 오류', '7320' => '수신 차단', '7325' => '변수 길이 초과', + '7421' => '타임아웃', '7436' => '지갑 잔액 부족(알림톡)', + '7437' => '메시지 요청 실패', '7523' => '기타 오류', ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index db2777db..761931e6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -247,6 +247,16 @@ class Plugin extends AbstractPlugin 'sensitive' => true, 'required' => false, ], + 'template_cache_minutes' => [ + 'type' => 'number', + 'default' => 60, + 'label' => ['ko' => '알림톡 내용 캐시 시간(분)', 'en' => 'Alimtalk content cache (minutes)'], + 'hint' => [ + 'ko' => '카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용합니다(기본 60분). 이 시간이 지나면 다음 발송 때 최신 내용을 다시 가져옵니다. 0으로 두면 매번 최신 내용을 가져옵니다 — 발송이 많으면 조회 제한에 걸릴 수 있어 권장하지 않습니다. 카카오에서 템플릿을 방금 수정했다면 아래 [캐시 초기화]로 즉시 반영할 수 있습니다.', + 'en' => 'Reuses Kakao alimtalk template content for this period (default 60 minutes). After it expires, the latest content is fetched on the next dispatch. Set to 0 to always fetch the latest — not recommended for high volume as it may hit rate limits. If you just edited a template in Kakao, use [Clear cache] below to apply it immediately.', + ], + 'required' => false, + ], ]; } @@ -267,6 +277,7 @@ class Plugin extends AbstractPlugin 'api_key' => '', 'sender_number' => '', 'sender_key' => '', + 'template_cache_minutes' => 60, ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json index 1bb467e1..9d4c2d1e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json @@ -37,6 +37,19 @@ }, "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}" }, + { + "comment": "소실 경고 — 연결됨이지만 연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가(is_unavailable). 승인 목록 대조가 가능했고(=is_unavailable 필드 존재) true 일 때만 표시. 카카오 조회 실패 시 서비스가 필드를 부여하지 않아 배지도 뜨지 않는다(오탐 방지).", + "type": "basic", + "name": "Span", + "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.is_unavailable === true}}", + "props": { + "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 flex-shrink-0" + }, + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "fas fa-triangle-exclamation", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.unavailable" } + ] + }, { "comment": "⑧ SMS 대체발송 배지 — 연결됨일 때만, 켜짐/꺼짐을 색으로 구분해 둘 다 표시(다크에서 묽지 않게 solid 색)", "type": "basic", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx index fc8c8229..a76d8f43 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx @@ -92,9 +92,11 @@ describe('alimtalk templates — 목록 서브뷰 (조회 전용)', () => { expect(raw).toContain('templateKeyword'); }); - it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다', () => { + it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다 (캐시 초기화는 환경설정 탭으로 이관)', () => { const raw = JSON.stringify(findById(root, 'templates_toolbar')); expect(raw).toContain('templates.list.refresh'); + // 캐시 초기화·캐시 시간은 환경설정 탭으로 옮겼으므로 템플릿 툴바에는 없다. + expect(raw).not.toContain('cache/clear'); // 등록 진입(폼 전환) 없음 expect(raw).not.toContain('templates.list.new'); expect(raw).not.toContain('"form"'); @@ -280,3 +282,28 @@ describe('alimtalk templates — i18n 정합', () => { } }); }); + +describe('alimtalk templates — 환경설정 탭: 발송 내용 캐시 (독립 카드)', () => { + it('발송 내용 캐시가 독립 카드로 있고 캐시 시간(분) 입력을 담는다 (form 저장 대상)', () => { + const card = findById(root, 'cache_section'); + expect(card).toBeTruthy(); + const raw = JSON.stringify(card); + // 카드 제목 + 분 단위 숫자 입력 + form 바인딩(name) + expect(raw).toContain('settings.cache.section_title'); + expect(raw).toContain('template_cache_minutes'); + expect(raw).toContain('"number"'); + expect(raw).toContain('settings.fields.template_cache_minutes.label'); + expect(raw).toContain('settings.fields.template_cache_minutes.hint'); + }); + + it('같은 카드에 캐시 초기화 버튼 + 안내문이 함께 있다 (한 묶음)', () => { + const raw = JSON.stringify(findById(root, 'cache_section')); + // 즉시 캐시 비우기 — API 호출 + 성공/실패 토스트 + 상시 안내문 + expect(raw).toContain('alimtalk-templates/cache/clear'); + expect(raw).toContain('apiCall'); + expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache'); + expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_hint'); + expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_success'); + expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_failed'); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx index fc420452..a3637a10 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -112,6 +112,15 @@ describe('binding UI — 행 연결(extension_point)', () => { expect(raw).toContain('modal_bizppurio_binding'); }); + it('연결된 카카오 템플릿이 소실(is_unavailable)이면 빨간 경고 배지를 표시한다(결함 2)', () => { + const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding')); + // 연결됨(template_code 있음) + is_unavailable === true 일 때만 경고 + expect(raw).toContain('is_unavailable === true'); + expect(raw).toContain('binding.unavailable'); + // 소실 경고는 red 배지로 표시(연결됨 초록과 구분) + expect(raw).toContain('bg-red-100'); + }); + it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => { const row = findById(footerRoot, 'bizppurio_row_binding'); const raw = JSON.stringify(row); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index f3a28ec8..5d7f071e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -65,6 +65,20 @@ describe('plugin_settings.json — 섹션', () => { expect(findById(root, id)).toBeTruthy(); }); + it('준비 안내 박스는 총괄 안내(intro)와 콘솔 링크를 구분선 위에 먼저 노출한다', () => { + const notice = findById(root, 'preparation_notice'); + const raw = JSON.stringify(notice); + // 총괄 안내 문구 키 + 콘솔 링크가 존재한다 + expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.intro'); + expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.console_link'); + // 총괄 안내가 채널별 준비 목록(sms_label)보다 먼저 배치된다 (이미지1 구조) + const introIdx = raw.indexOf('preparation.intro'); + const smsIdx = raw.indexOf('preparation.sms_label'); + expect(introIdx).toBeGreaterThanOrEqual(0); + expect(smsIdx).toBeGreaterThanOrEqual(0); + expect(introIdx).toBeLessThan(smsIdx); + }); + it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => { const card = findById(root, 'test_mode_card'); expect(card).toBeTruthy(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index 2ac93845..0cff3388 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -44,6 +44,14 @@ "sender_key": { "label": "Alimtalk Sender Profile Key", "hint": "The 40-character sender profile key used for alimtalk delivery and template lookup." + }, + "template_cache_minutes": { + "label": "Alimtalk content cache (minutes)", + "hint": "Reuses Kakao template content for this period. 0 = always latest (not recommended for high volume).", + "clear_cache": "Clear cache", + "clear_cache_hint": "If you edited a template in Kakao, click to apply it immediately.", + "clear_cache_success": "Template content cache cleared. The latest content will apply from the next dispatch.", + "clear_cache_failed": "Failed to clear the cache." } }, "report": { @@ -53,11 +61,16 @@ "copy": "Copy", "copied": "Report endpoint copied to clipboard." }, + "cache": { + "section_title": "Alimtalk dispatch content cache", + "section_description": "When sending alimtalk, the template content (body and buttons) registered in Kakao must be sent as-is. To avoid requesting the content from Kakao on every dispatch, the fetched content is reused (cached) for a set period. This keeps sending fast and avoids hitting Kakao's lookup rate limit even under high volume." + }, "tabs": { "connection": "Settings", "templates": "Alimtalk Templates" }, "preparation": { + "intro": "To send SMS or Kakao Alimtalk messages, first complete the preparations below in the Bizppurio console.", "sms_label": "SMS/LMS", "sms_sender": "Register your sender number.", "kakao_label": "Kakao Alimtalk", @@ -73,6 +86,7 @@ "section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.", "modal_title": "Alimtalk Binding · {name}", "unbound": "Not connected", + "unavailable": "Unavailable — reconnect needed", "btn_connect": "Connect", "btn_change": "Change", "fallback_on": "SMS fallback ON", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index fab7e0fb..8fb50616 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -44,6 +44,14 @@ "sender_key": { "label": "알림톡 발신프로필 키", "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다." + }, + "template_cache_minutes": { + "label": "알림톡 내용 캐시 시간(분)", + "hint": "카카오 템플릿 내용을 이 시간만큼 재사용합니다. 0 = 매번 최신(발송 많으면 비권장).", + "clear_cache": "캐시 초기화", + "clear_cache_hint": "카카오에서 템플릿을 수정했다면 눌러서 즉시 반영하세요.", + "clear_cache_success": "템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.", + "clear_cache_failed": "캐시 초기화에 실패했습니다." } }, "report": { @@ -53,11 +61,16 @@ "copy": "복사", "copied": "리포트 수신 주소가 클립보드에 복사되었습니다." }, + "cache": { + "section_title": "알림톡 발송 내용 캐시", + "section_description": "알림톡은 발송할 때 카카오에 등록된 템플릿 내용(본문·버튼)을 그대로 보내야 합니다. 발송할 때마다 카카오에 내용을 요청하지 않도록, 한 번 가져온 내용을 일정 시간 재사용(캐시)합니다. 덕분에 발송이 많아도 카카오 조회 제한에 걸리지 않고 빠르게 발송됩니다." + }, "tabs": { "connection": "환경설정", "templates": "알림톡 템플릿" }, "preparation": { + "intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.", "sms_label": "문자(SMS/LMS)", "sms_sender": "발신번호를 등록하세요.", "kakao_label": "카카오 알림톡", @@ -73,6 +86,7 @@ "section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.", "modal_title": "알림톡 연결 · {name}", "unbound": "미연결", + "unavailable": "사용 불가 — 재연결 필요", "btn_connect": "연결", "btn_change": "연결 변경", "fallback_on": "SMS 대체 ON", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index f8b30598..6510891f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -154,37 +154,58 @@ "children": [ { "id": "preparation_notice", - "comment": "사용 전 준비 안내(통합 1박스) — 실제 비즈뿌리오 사전 준비 절차. 박스 제목 없이 채널 그룹 2개만(문자·카카오)로 위계 단순화. 문자(발신번호 1줄) → 카카오 알림톡(채널·발신프로필 / 템플릿 등록·승인 / 고객센터 API 키 요청 3줄) 순. 각 채널은 라벨(굵게) + 들여쓴 하위 불릿. gap-4 로 그룹 간격.", + "comment": "사용 전 준비 안내(통합 1박스) — 알림톡 템플릿 탭 list_notice 와 동일 구조. 위: 총괄 안내 1줄 + 콘솔 열기 링크. 구분선(border-t). 아래: 문자·카카오 채널별 사전 준비 목록(라벨 굵게 + 들여쓴 하위 불릿).", "type": "basic", "name": "Div", - "props": { "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4 flex flex-col gap-4" }, + "props": { "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" }, "children": [ { - "comment": "문자(SMS/LMS) — 라벨 + 발신번호 등록 1줄", + "comment": "위 — 총괄 안내 1줄 + 콘솔 열기 링크", "type": "basic", "name": "Div", "props": { "className": "space-y-1" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] } + { + "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, + "children": [ + { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.intro" } + ] + }, + { + "type": "basic", "name": "A", + "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" }, + "children": [ + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" }, + { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } } + ] + } ] }, { - "comment": "카카오 알림톡 — 라벨 + 채널/템플릿/API키 3줄", + "comment": "구분선 아래 — 문자·카카오 채널별 사전 준비 목록", "type": "basic", "name": "Div", - "props": { "className": "space-y-1" }, + "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 flex flex-col gap-4" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] }, - { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] } - ] - }, - { - "type": "basic", "name": "A", - "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 pt-1 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" }, - "children": [ - { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" }, - { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } } + { + "comment": "문자(SMS/LMS) — 라벨 + 발신번호 등록 1줄", + "type": "basic", "name": "Div", + "props": { "className": "space-y-1" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] } + ] + }, + { + "comment": "카카오 알림톡 — 라벨 + 채널/템플릿/API키 3줄", + "type": "basic", "name": "Div", + "props": { "className": "space-y-1" }, + "children": [ + { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] }, + { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] } + ] + } ] } ] @@ -803,6 +824,146 @@ } ] }, + { + "id": "cache_section", + "comment": "알림톡 발송 내용 캐시 — 독립 카드. 캐시 시간(form 저장) + 캐시 초기화(즉시 API, 로컬 캐시만 비움).", + "type": "basic", + "name": "Div", + "props": { + "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "panel-header-row" }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { "className": "section-heading-md" }, + "text": "$t:sirsoft-message_bizppurio.settings.cache.section_title" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint mt-1" }, + "text": "$t:sirsoft-message_bizppurio.settings.cache.section_description" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "p-6 space-y-6" }, + "children": [ + { + "comment": "캐시 시간(분) — 좌: 라벨+설명 / 우: 입력. 다른 설정 필드와 동일한 2단 grid.", + "type": "basic", + "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-8 flex lg:justify-end" }, + "children": [ + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "template_cache_minutes", + "min": "0", + "autoComplete": "off", + "className": "input w-32" + } + } + ] + } + ] + }, + { + "comment": "캐시 초기화 — 좌: 안내문 / 우: 버튼. 즉시 로컬 캐시 비우기(kapi 호출 없음).", + "type": "basic", + "name": "Div", + "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-4" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-8 flex lg:justify-end" }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" }, + "actions": [ + { + "type": "click", + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear", + "params": { "method": "POST" }, + "onSuccess": [ + { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_success" } } + ], + "onError": [ + { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_failed" } } + ] + } + ], + "children": [ + { "type": "basic", "name": "Icon", "props": { "name": "eraser", "size": "sm" } }, + { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache" } + ] + } + ] + } + ] + } + ] + } + ] + }, { "id": "footer_buttons", "type": "basic", @@ -1196,7 +1357,7 @@ ] }, { - "comment": "우측 액션 그룹 — 조회 전용(새로고침만). 템플릿 등록은 비즈뿌리오 콘솔로 위임.", + "comment": "우측 액션 그룹 — 조회 전용(새로고침만). 캐시 초기화·캐시 시간은 환경설정 탭으로 이관. 템플릿 등록은 비즈뿌리오 콘솔로 위임.", "type": "basic", "name": "Div", "props": { "className": "flex gap-2 items-center" }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php index 14e5e27d..a6e32444 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -10,6 +10,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; +use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService; /** * 알림톡 템플릿 조회 컨트롤러 (Phase 5). @@ -30,9 +31,11 @@ class AlimtalkTemplateController extends AdminBaseController /** * @param AlimtalkTemplateService $service 알림톡 템플릿 서비스 + * @param NotificationBindingService $bindings 연동 서비스(발송 내용 캐시 초기화 위임) */ public function __construct( private readonly AlimtalkTemplateService $service, + private readonly NotificationBindingService $bindings, ) { parent::__construct(); } @@ -96,6 +99,24 @@ class AlimtalkTemplateController extends AdminBaseController ])); } + /** + * 발송용 템플릿 내용 캐시를 초기화합니다 (관리자 수동 갱신). + * + * 카카오에서 템플릿 내용을 방금 변경해 캐시 만료(기본 1시간)를 기다리지 않고 즉시 + * 반영하고 싶을 때 호출한다. 연결된 모든 알림톡 템플릿의 캐시를 비워, 다음 발송에서 + * 최신 내용으로 재조회되게 한다. kapi 호출 없이 로컬 캐시만 비우므로 rate limit 영향 없음. + * + * @return JsonResponse data.cleared 에 초기화한 캐시 수 + */ + public function clearCache(): JsonResponse + { + $cleared = $this->bindings->clearTemplateContentCache(); + + return ResponseHelper::success('messages.cache.cleared', [ + 'cleared' => $cleared, + ]); + } + // kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환하는 guard() 는 // GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유). // 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 조회 실패 원인을 즉시 파악한다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php index c7778ce6..3294a69e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php @@ -48,8 +48,10 @@ class NotificationBindingController extends AdminBaseController */ public function index(): JsonResponse { + // 알림톡 탭 진입 표시용 — 카카오 승인 목록과 대조해 소실(발송 불가) 연동을 함께 표시한다. + // 카카오 조회 실패 시 서비스가 판정을 생략하므로 이 조회가 목록 표시를 막지 않는다. return ResponseHelper::success('messages.success', [ - 'bindings' => $this->service->all(), + 'bindings' => $this->service->all(withAvailability: true), ]); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php index ac24d448..22a082b8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -1,5 +1,7 @@ templateService->resolve($type, DispatchChannel::Alimtalk->value); - if ($template === null || ! $template->is_active) { - Log::info('비즈뿌리오 알림톡 발송 skip — alimtalk 템플릿 없음', ['type' => $type]); + // 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로, + // templatecode 만으로는 안 되고 카카오에 등록된 실제 내용을 가져와 채운다(B안). + // 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다. + $kakaoContent = $this->kakaoContent->resolve($binding->template_code); + if ($kakaoContent === null) { + Log::info('비즈뿌리오 알림톡 발송 skip — 카카오 템플릿 내용 조회 실패', [ + 'type' => $type, + 'template_code' => $binding->template_code, + ]); return; } @@ -102,22 +117,27 @@ class AlimtalkChannelDriver return; } - // 4. 본문 렌더 (변수 치환) → 알림톡 변수 형식으로 변환 ({x} → #{x}) - $locale = BaseNotification::resolveNotifiableLocale($notifiable); - $rendered = $template->replaceVariables($notification->getData(), $locale); - $message = $this->toAlimtalkVariables((string) ($rendered['body'] ?? '')); + // 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip. + $mapped = $this->payloadMapper->map($kakaoContent, $notification->getData()); + $message = (string) ($mapped['message'] ?? ''); if (trim($message) === '') { Log::info('비즈뿌리오 알림톡 발송 skip — 본문 비어 있음', ['type' => $type]); return; } - // 5. refkey 생성 → payload 조립 (대체발송 ON 시 SMS resend 병합) + // 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합) $refkey = $this->generateRefkey(); - $payload = $this->payloadBuilder->buildAlimtalk($to, $binding->template_code, $message, $refkey); + $payload = $this->payloadBuilder->buildAlimtalk( + $to, + $binding->template_code, + $message, + $refkey, + (array) ($mapped['extra'] ?? []), + ); if ($binding->fallback_sms_enabled) { - $payload = $this->withSmsFallback($payload, (string) ($rendered['body'] ?? '')); + $payload = $this->withSmsFallback($payload, $this->smsFallbackBody($type, $notifiable, $notification)); } // 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신. @@ -142,34 +162,48 @@ class AlimtalkChannelDriver } /** - * 코어 알림 본문의 변수 형식({name})을 알림톡 변수 형식(#{name})으로 변환합니다 (D8). + * SMS 대체발송 본문을 코어 alimtalk 템플릿에서 렌더합니다 (B안). * - * 코어 알림 템플릿과 카카오 알림톡은 변수명 규칙이 동일하되 표기만 다르다. 변수명을 그대로 - * 유지한 채 접두 `#` 만 붙여, 매핑 UI·컬럼 없이 자동 치환한다. 이미 `#{...}` 인 경우는 - * 중복 변환하지 않는다. + * 알림톡 본문은 카카오 승인 템플릿에서 오지만, 실패 시 대체할 SMS 본문은 카카오와 무관하므로 + * 코어 알림 템플릿(alimtalk 채널) 본문을 그대로 재사용한다. 코어 템플릿이 없거나 비활성이면 + * 빈 문자열을 반환하고, withSmsFallback 이 빈 본문이면 대체를 병합하지 않는다(엣지 C2). * - * @param string $body 코어 본문 (예: "{name} 님 주문 {order_number}") - * @return string 알림톡 본문 (예: "#{name} 님 주문 #{order_number}") + * @param string $type 알림 유형 + * @param object $notifiable 수신자 + * @param GenericNotification $notification 발송 대상 알림 + * @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열) */ - private function toAlimtalkVariables(string $body): string + private function smsFallbackBody(string $type, object $notifiable, GenericNotification $notification): string { - // 앞에 # 가 없는 {var} 만 #{var} 로 변환 (이미 #{var} 인 경우 보존). - return (string) preg_replace('/(?templateService->resolve($type, DispatchChannel::Alimtalk->value); + if ($template === null || ! $template->is_active) { + return ''; + } + + $locale = BaseNotification::resolveNotifiableLocale($notifiable); + $rendered = $template->replaceVariables($notification->getData(), $locale); + + return (string) ($rendered['body'] ?? ''); } /** * 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송, 계획서 §6-2). * * 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. 대체 SMS 본문은 - * 코어 알림 본문(원문 {var} 형식이 아니라 실제 치환 완료 텍스트)을 재사용한다. 부록 C-2 의 - * `resend:{first:"sms"}` + `recontent:{sms:{message}}` 구조를 따른다. + * 코어 알림 본문(치환 완료 텍스트)을 재사용한다. 부록 C-2 의 `resend:{first:"sms"}` + + * `recontent:{sms:{message}}` 구조를 따른다. 대체 본문이 비어 있으면(코어 템플릿 부재) + * 빈 SMS 를 보내지 않도록 병합하지 않는다(엣지 C2). * * @param array $payload 알림톡 발송 payload * @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용) - * @return array resend/recontent 가 병합된 payload + * @return array resend/recontent 가 병합된 payload (빈 본문이면 원본 그대로) */ private function withSmsFallback(array $payload, string $renderedBody): array { + if (trim($renderedBody) === '') { + return $payload; + } + $payload['resend'] = ['first' => 'sms']; $payload['recontent'] = ['sms' => ['message' => $renderedBody]]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php new file mode 100644 index 00000000..8efb4f6e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php @@ -0,0 +1,261 @@ + + */ + private const LINK_FIELD_MAP = [ + 'linkMo' => 'url_mobile', + 'linkPc' => 'url_pc', + 'linkAnd' => 'scheme_android', + 'linkIos' => 'scheme_ios', + ]; + + /** + * 카카오 상세조회 응답을 발송 API content.at 형식으로 변환합니다. + * + * @param array $detail 카카오 상세조회 응답(templateContent/buttons/…) + * @param array $data 알림 data (변수 치환 소스: {key => value}) + * @return array{message: string, extra: array} 치환 완료 본문 + extra + */ + public function map(array $detail, array $data): array + { + $message = $this->substitute((string) ($detail['templateContent'] ?? ''), $data); + + $extra = []; + + if ($button = $this->mapButtons($detail['buttons'] ?? null, $data)) { + $extra['button'] = $button; + } + + if ($quickreply = $this->mapButtons($detail['quickReplies'] ?? null, $data)) { + $extra['quickreply'] = $quickreply; + } + + if ($title = $this->substitute((string) ($detail['templateTitle'] ?? ''), $data)) { + $extra['title'] = $title; + } + + if ($header = $this->substitute((string) ($detail['templateHeader'] ?? ''), $data)) { + $extra['header'] = $header; + } + + if ($item = $this->mapItem($detail['templateItem'] ?? null, $data)) { + $extra['item'] = $item; + } + + if ($highlight = $this->mapItemHighlight($detail['templateItemHighlight'] ?? null, $data)) { + $extra['itemhighlight'] = $highlight; + } + + if ($link = $this->mapLinkFields($detail['templateRepresentLink'] ?? null, $data)) { + $extra['link'] = $link; + } + + return ['message' => $message, 'extra' => $extra]; + } + + /** + * 버튼/바로연결 배열을 발송 형식으로 변환합니다 (동일 규칙 공유). + * + * @param mixed $buttons 카카오 buttons/quickReplies 배열 + * @param array $data 변수 치환 소스 + * @return array> 발송 button/quickreply 배열 (없으면 빈 배열) + */ + private function mapButtons(mixed $buttons, array $data): array + { + if (! is_array($buttons) || $buttons === []) { + return []; + } + + $mapped = []; + + foreach ($buttons as $button) { + if (! is_array($button)) { + continue; + } + + $row = []; + + if (isset($button['name'])) { + $row['name'] = $this->substitute((string) $button['name'], $data); + } + + if (isset($button['linkType'])) { + $row['type'] = (string) $button['linkType']; + } + + // 링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 필드 + URL 변수 치환. + foreach (self::LINK_FIELD_MAP as $from => $to) { + if (isset($button[$from]) && $button[$from] !== '') { + $row[$to] = $this->substitute((string) $button[$from], $data); + } + } + + // 전화·플러그인 등 부가 필드. + if (isset($button['telNumber']) && $button['telNumber'] !== '') { + $row['tel_number'] = (string) $button['telNumber']; + } + + if (isset($button['pluginId']) && $button['pluginId'] !== '') { + $row['plugin_id'] = (string) $button['pluginId']; + } + + if ($row !== []) { + $mapped[] = $row; + } + } + + return $mapped; + } + + /** + * 대표링크/링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 형식으로 변환합니다. + * + * @param mixed $link 카카오 templateRepresentLink + * @param array $data 변수 치환 소스 + * @return array 발송 link 필드 (없으면 빈 배열) + */ + private function mapLinkFields(mixed $link, array $data): array + { + if (! is_array($link) || $link === []) { + return []; + } + + $mapped = []; + + foreach (self::LINK_FIELD_MAP as $from => $to) { + if (isset($link[$from]) && $link[$from] !== '') { + $mapped[$to] = $this->substitute((string) $link[$from], $data); + } + } + + return $mapped; + } + + /** + * 아이템리스트(list/summary)를 변환하고 각 필드를 치환합니다. + * + * @param mixed $item 카카오 templateItem + * @param array $data 변수 치환 소스 + * @return array 발송 item (없으면 빈 배열) + */ + private function mapItem(mixed $item, array $data): array + { + if (! is_array($item) || $item === []) { + return []; + } + + $mapped = []; + + if (isset($item['list']) && is_array($item['list'])) { + $list = []; + foreach ($item['list'] as $entry) { + if (! is_array($entry)) { + continue; + } + $list[] = [ + 'title' => $this->substitute((string) ($entry['title'] ?? ''), $data), + 'description' => $this->substitute((string) ($entry['description'] ?? ''), $data), + ]; + } + if ($list !== []) { + $mapped['list'] = $list; + } + } + + if (isset($item['summary']) && is_array($item['summary'])) { + $mapped['summary'] = [ + 'title' => $this->substitute((string) ($item['summary']['title'] ?? ''), $data), + 'description' => $this->substitute((string) ($item['summary']['description'] ?? ''), $data), + ]; + } + + return $mapped; + } + + /** + * 아이템 하이라이트(title/description)를 변환하고 치환합니다. + * + * @param mixed $highlight 카카오 templateItemHighlight + * @param array $data 변수 치환 소스 + * @return array 발송 itemhighlight (없으면 빈 배열) + */ + private function mapItemHighlight(mixed $highlight, array $data): array + { + if (! is_array($highlight) || $highlight === []) { + return []; + } + + $mapped = []; + + if (isset($highlight['title'])) { + $mapped['title'] = $this->substitute((string) $highlight['title'], $data); + } + + if (isset($highlight['description'])) { + $mapped['description'] = $this->substitute((string) $highlight['description'], $data); + } + + return $mapped; + } + + /** + * 카카오 변수(#{key})를 알림 data 값으로 치환합니다. + * + * 카카오 템플릿 변수와 코어 알림 data 는 변수명 규칙이 동일하다(표기만 #{} vs {}). + * data 에 없는 변수는 원문(#{key})을 유지한다 — 카카오가 변수 불일치로 판단하게 두어, + * 우리가 임의로 빈 값을 채워 잘못된 발송을 하지 않는다. + * + * @param string $text 치환 대상(#{key} 포함) + * @param array $data 변수 치환 소스 + * @return string 치환된 문자열 + */ + private function substitute(string $text, array $data): string + { + if ($text === '' || $data === []) { + return $text; + } + + $replacements = []; + foreach ($data as $key => $value) { + if (is_scalar($value) || $value === null) { + $replacements['#{'.$key.'}'] = (string) $value; + } + } + + return strtr($text, $replacements); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php index e17ff602..2321a85f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php @@ -131,9 +131,14 @@ class AlimtalkTemplateService } /** - * 발신프로필(사용중) 정보를 조회합니다. + * 발신프로필(사용중) 목록을 조회합니다. * - * @return array 발신프로필 응답 data + * 규격(5.발신프로필관리): `/v3/kakao/profile/use` 응답의 data 는 + * `{success: [...프로필], fail: [...조회실패]}` 2단 봉투다. 실제 발신프로필 목록은 + * data.success 배열에 담기므로 그 배열을 반환한다(data 통째 반환 시 success/fail + * 껍데기가 소비처에 그대로 노출됨). + * + * @return array> 발신프로필 목록(data.success) * * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 */ @@ -143,7 +148,7 @@ class AlimtalkTemplateService $this->assertSuccess($response); - return (array) ($response['data'] ?? []); + return array_values((array) ($response['data']['success'] ?? [])); } /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php new file mode 100644 index 00000000..523d0260 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php @@ -0,0 +1,193 @@ +0 이면 캐시 우선(없으면 조회 후 캐시), TTL=0 이면 캐시 우회(매번 조회). + * 조회 실패는 null 을 반환한다. + * + * @param string $templateCode 카카오 템플릿 코드 + * @return array|null 카카오 상세(templateContent/buttons/…) 또는 실패 시 null + */ + public function resolve(string $templateCode): ?array + { + $ttl = $this->cacheTtl(); + + // TTL=0 → 캐시 우회, 매번 실시간 조회. + if ($ttl <= 0) { + return $this->fetch($templateCode); + } + + $cacheKey = self::CACHE_KEY_PREFIX.$templateCode; + + // 캐시 히트면 그대로 반환. 미스면 조회해 캐시. + $cached = $this->cache->get($cacheKey); + if (is_array($cached)) { + return $cached; + } + + $content = $this->fetch($templateCode); + + // 조회 실패(null)는 캐시하지 않는다 — 다음 발송에서 재시도 가능하도록. + if ($content !== null) { + $this->cache->put($cacheKey, $content, $ttl); + } + + return $content; + } + + /** + * 특정 템플릿의 캐시를 무효화합니다 (관리자 수동 초기화). + * + * @param string $templateCode 카카오 템플릿 코드 + */ + public function clear(string $templateCode): void + { + $this->cache->forget(self::CACHE_KEY_PREFIX.$templateCode); + } + + /** + * 여러 템플릿의 캐시를 한 번에 무효화합니다 (관리자 전체 초기화). + * + * 캐시 키가 template_code 단위이므로, 연결된 모든 알림톡 템플릿 코드를 받아 각각 비운다. + * 다음 발송에서 최신 내용으로 재조회된다. + * + * @param array $templateCodes 카카오 템플릿 코드 목록 + * @return int 비운 캐시 키 수(중복 제거 후) + */ + public function clearMany(array $templateCodes): int + { + $codes = array_values(array_unique(array_filter($templateCodes, static fn ($c) => $c !== ''))); + + foreach ($codes as $code) { + $this->clear((string) $code); + } + + return count($codes); + } + + /** + * 카카오 상세조회를 수행합니다. 실패는 null(예외 삼킴 + 로그). + * + * rate limit(HTTP 429 또는 결과코드 5002)은 조회 폭주 상황이므로 짧게 대기 후 1회 + * 재시도한다(무한 방지). 재시도해도 rate limit 이면 null 을 반환해 호출측이 발송을 skip + * 하게 한다. 그 외 실패(고아 template_code·자격증명 오류 등)는 재시도 없이 null. + * + * @param string $templateCode 카카오 템플릿 코드 + * @return array|null 상세 또는 실패 시 null + */ + private function fetch(string $templateCode): ?array + { + for ($attempt = 0; $attempt <= self::RATE_LIMIT_RETRIES; $attempt++) { + try { + return $this->templates->detail($templateCode); + } catch (BizppurioApiException $e) { + // rate limit 이고 재시도 여유가 남았으면 짧게 대기 후 재시도. + if ($this->isRateLimited($e) && $attempt < self::RATE_LIMIT_RETRIES) { + usleep(self::RATE_LIMIT_RETRY_WAIT_US); + + continue; + } + + Log::warning('비즈뿌리오 알림톡 템플릿 내용 조회 실패 — 발송 skip 후보', [ + 'template_code' => $templateCode, + 'result_code' => $e->getResultCode(), + 'http_status' => $e->getHttpStatus(), + 'message' => $e->getMessage(), + ]); + + return null; + } + } + + return null; + } + + /** + * 예외가 rate limit(HTTP 429 또는 결과코드 5002)인지 판정합니다. + * + * @param BizppurioApiException $e 조회 실패 예외 + * @return bool rate limit 이면 true + */ + private function isRateLimited(BizppurioApiException $e): bool + { + return $e->getHttpStatus() === 429 || $e->getResultCode() === '5002'; + } + + /** + * 캐시 TTL(초)을 환경설정에서 조회합니다 (기본 60분, 0=캐시 끔). + * + * 관리자 화면·저장은 "분" 단위(template_cache_minutes)로 관리하고, 캐시 드라이버에는 + * 초 단위가 필요하므로 60 을 곱해 반환한다. 0 이면 캐시를 끄므로 0 그대로 반환한다. + * + * @return int TTL(초) + */ + private function cacheTtl(): int + { + $minutes = (int) $this->pluginSettings->get( + self::PLUGIN_IDENTIFIER, + 'template_cache_minutes', + self::DEFAULT_CACHE_MINUTES, + ); + + return max(0, $minutes) * 60; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php index eba3bd60..edf21004 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php @@ -34,10 +34,12 @@ class NotificationBindingService /** * @param BizppurioNotificationBindingRepositoryInterface $bindings 연동 조회/저장 * @param AlimtalkTemplateService $templates 카카오 승인 템플릿 조회 + * @param KakaoTemplateContentResolver $kakaoContent 발송용 템플릿 내용 캐시(수동 초기화 위임) */ public function __construct( private readonly BizppurioNotificationBindingRepositoryInterface $bindings, private readonly AlimtalkTemplateService $templates, + private readonly KakaoTemplateContentResolver $kakaoContent, ) {} /** @@ -47,21 +49,57 @@ class NotificationBindingService * 데 쓴다. 코어 목록은 코어가 렌더하므로(⚑⚑ 결정 A) 알림 정의와 조인하지 않고 binding * 만 내려준다. 편집 모달은 def.type 으로 이 맵을 조회한다. * + * $withAvailability=true 이면(알림톡 탭 진입 표시용) 카카오 승인 목록을 1회 조회해 각 연동에 + * is_unavailable(연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가) 플래그를 부여한다. + * 저장 응답(store)처럼 프리필만 필요한 경로는 기본값(false)으로 호출해 카카오 조회를 생략한다. + * + * @param bool $withAvailability 카카오 승인 목록과 대조해 소실 여부를 부여할지 (표시용) * @return array> notification_type 키의 연동 맵 */ - public function all(): array + public function all(bool $withAvailability = false): array { + // 소실 판정 대상 = 발송 가능한(승인) 카카오 템플릿 코드 집합. 조회 실패(카카오 장애·자격증명 + // 미설정)면 null 을 반환해 판정 자체를 건너뛴다 — 살아 있는 연동이 일시 장애로 "사용 불가"로 + // 오탐되어 화면이 전부 경고로 물드는 것을 막는다(발송 시점 판정과 동일한 안전측 기준). + $sendableCodes = $withAvailability ? $this->sendableTemplateCodesOrNull() : null; + return $this->bindings->allByChannel(self::CHANNEL) ->keyBy('notification_type') - ->map(fn (BizppurioNotificationBinding $binding) => [ - 'notification_type' => $binding->notification_type, - 'template_code' => $binding->template_code, - 'template_name' => $binding->template_name, - 'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled, - ]) + ->map(function (BizppurioNotificationBinding $binding) use ($sendableCodes) { + $info = [ + 'notification_type' => $binding->notification_type, + 'template_code' => $binding->template_code, + 'template_name' => $binding->template_name, + 'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled, + ]; + + // 승인 목록 조회에 성공했을 때만 소실 여부를 부여한다(null=판정 생략). + if ($sendableCodes !== null) { + $info['is_unavailable'] = ! in_array($binding->template_code, $sendableCodes, true); + } + + return $info; + }) ->all(); } + /** + * 발송 가능한(승인) 카카오 템플릿 코드 집합을 반환하되, 조회 실패 시 null 을 반환합니다. + * + * approvedTemplates() 는 자격증명 미설정·카카오 장애 시 BizppurioApiException 을 던진다. 소실 + * 판정은 "표시 부가정보"이므로 실패를 삼키고 null 을 반환해, 호출부가 판정을 건너뛰게 한다. + * + * @return array|null 승인 template_code 목록(성공) 또는 null(조회 실패) + */ + private function sendableTemplateCodesOrNull(): ?array + { + try { + return array_column($this->approvedTemplates(), 'template_code'); + } catch (BizppurioApiException) { + return null; + } + } + /** * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운). * @@ -116,6 +154,24 @@ class NotificationBindingService $this->bindings->delete($notificationType, self::CHANNEL); } + /** + * 연결된 모든 알림톡 템플릿의 발송용 내용 캐시를 초기화합니다 (관리자 수동 갱신). + * + * 카카오에서 템플릿 내용을 방금 바꿔 캐시 만료(기본 1시간)를 기다리지 않고 즉시 반영하고 + * 싶을 때 사용한다. 연결(binding)된 template_code 를 모아 각 캐시를 비우면 다음 발송에서 + * 최신 내용으로 재조회된다. + * + * @return int 초기화한 캐시 키 수(연결된 고유 template_code 수) + */ + public function clearTemplateContentCache(): int + { + $codes = $this->bindings->allByChannel(self::CHANNEL) + ->pluck('template_code') + ->all(); + + return $this->kakaoContent->clearMany($codes); + } + /** * 코어 편집 모달 전용 칸이 넘긴 값으로 연동을 반영합니다 (저장 훅 편승). * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php index 7a592863..ce003f89 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php @@ -25,8 +25,13 @@ class ResultCodeResolver /** * 재시도(일시 오류) 코드 — 큐가 재시도해야 하는 코드. + * + * 공통 일시오류(5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류 + * (7306 카카오 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 포함한다. + * 7305(성공 불확실)는 이미 발송됐을 수 있어 중복발송 위험이 있으므로 제외한다. + * 출처: 비즈뿌리오 공식 응답코드(bizppurio.github.io/response-codes). */ - private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013']; + private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437']; /** * 잔액 부족 코드 — 문자 9070 / 알림톡 7436 (D3 자체 알림 대상). diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 8f45d301..78df0822 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -83,6 +83,13 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g | 페이지 알림톡 템플릿 탭이 소비한다. */ Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () { + // 발송 템플릿 내용 캐시 초기화(수동 갱신) — 카카오에서 템플릿을 방금 바꿔 즉시 반영이 + // 필요할 때 관리자가 캐시를 비운다. 쓰기 동작이므로 messaging.manage 권한. 구체 경로를 + // 먼저 두어 GET /{templateCode} 와 충돌하지 않게 한다. + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { + Route::post('/cache/clear', [AlimtalkTemplateController::class, 'clearCache'])->name('cache.clear'); + }); + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { Route::get('/', [AlimtalkTemplateController::class, 'index'])->name('index'); Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories'); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php index dfd47b2e..54825362 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php @@ -9,6 +9,7 @@ use Mockery; use Mockery\MockInterface; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; +use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** @@ -190,6 +191,48 @@ class AlimtalkTemplateControllerTest extends PluginTestCase $response->assertJsonPath('errors.kakao_message', '발신프로필을 찾을 수 없습니다.'); } + /** + * @scenario auth=manage,action=clear_cache + * + * @effects clear_cache_delegates_to_binding_service_and_returns_count + */ + public function test_manage권한으로_발송내용_캐시를_초기화한다(): void + { + $bindings = Mockery::mock(NotificationBindingService::class); + $bindings->shouldReceive('clearTemplateContentCache')->once()->andReturn(3); + $this->app->instance(NotificationBindingService::class, $bindings); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage'])) + ->postJson(self::BASE.'/cache/clear'); + + $response->assertStatus(200); + $response->assertJsonPath('data.cleared', 3); + } + + /** + * @scenario auth=view_only,action=clear_cache + * + * @effects clear_cache_requires_manage_permission_returns_403 + */ + public function test_캐시초기화는_manage권한이_없으면_403(): void + { + // 조회(view) 권한만으로는 캐시 초기화(쓰기)를 할 수 없다. + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->postJson(self::BASE.'/cache/clear'); + + $response->assertStatus(403); + } + + /** + * @scenario auth=guest,action=clear_cache + * + * @effects clear_cache_requires_authentication_returns_401 + */ + public function test_캐시초기화는_비인증이면_401(): void + { + $this->postJson(self::BASE.'/cache/clear')->assertStatus(401); + } + protected function tearDown(): void { Mockery::close(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php index d97e6693..975f797e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -86,6 +86,22 @@ class SendMessageJobTest extends PluginTestCase } } + public function test_알림톡_일시오류_결과코드는_예외를_던져_재시도한다(): void + { + // 알림톡 일시오류(7306 카카오 시스템오류·7307 처리지연·7421 타임아웃·7437 요청실패)는 + // 다시 보내면 성공할 수 있으므로 예외를 던져 큐가 재시도해야 한다. + foreach (['7306', '7307', '7421', '7437'] as $code) { + $job = new SendMessageJob($this->payload, 'ref1'); + + try { + $job->handle($this->makeClient(['code' => (int) $code, 'description' => 'temp']), $this->dispatches()); + $this->fail("코드 {$code} 는 재시도(예외)여야 한다"); + } catch (BizppurioApiException $e) { + $this->assertSame($code, $e->getResultCode(), "코드 {$code}"); + } + } + } + public function test_영구실패_결과코드는_예외없이_종료하고_이력을_failed로_갱신한다(): void { $dispatch = $this->seedPending('ref1'); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php index 153934a8..8fddd63d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -16,7 +16,9 @@ use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver; +use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkPayloadMapper; use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext; +use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver; use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -73,25 +75,56 @@ class AlimtalkChannelDriverTest extends PluginTestCase return $binding; } + /** + * 카카오 상세조회 내용을 반환하는 KakaoTemplateContentResolver mock 을 만듭니다. + * + * @param array|null $content resolve 반환값 (null=조회 실패 → skip) + */ + private function fakeKakaoContent(?array $content): KakaoTemplateContentResolver + { + $resolver = Mockery::mock(KakaoTemplateContentResolver::class); + $resolver->shouldReceive('resolve')->andReturn($content); + + return $resolver; + } + + /** + * 기본 카카오 상세(본문만 있는 단순 승인 템플릿). + * + * @return array + */ + private function kakaoContent(string $templateContent = '#{name}님 주문이 완료되었습니다.'): array + { + return ['templateCode' => 'TW_1234', 'templateContent' => $templateContent]; + } + /** * 템플릿·binding·빌더를 조합한 driver 를 만듭니다. + * + * @param array|null $kakao 카카오 상세조회 내용 (null=조회 실패) */ private function makeDriver( ?NotificationTemplate $template, ?BizppurioNotificationBinding $binding, ?MessagePayloadBuilder $builder = null, + array|false|null $kakao = false, ): AlimtalkChannelDriver { $templateService = Mockery::mock(NotificationTemplateService::class); $templateService->shouldReceive('resolve') ->with(Mockery::any(), 'alimtalk') ->andReturn($template); + // $kakao 기본값 false = "기본 카카오 내용 제공"(대부분 테스트). null = 조회 실패. + $content = $kakao === false ? $this->kakaoContent() : $kakao; + return new AlimtalkChannelDriver( $templateService, $this->fakeBindings($binding), $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, new DispatchLinkContext, + $this->fakeKakaoContent($content), + new AlimtalkPayloadMapper, ); } @@ -143,17 +176,30 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->assertDatabaseCount('bizppurio_dispatches', 0); } - public function test_alimtalk_템플릿이_없으면_발송하지_않는다(): void + public function test_카카오_템플릿_내용_조회_실패시_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // binding 은 있으나 코어 alimtalk 본문 템플릿이 없음 → skip - $this->makeDriver(null, $this->binding())->send($member, $this->notification()); + // binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음 → skip + $this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null) + ->send($member, $this->notification()); Bus::assertNotDispatched(SendMessageJob::class); } + public function test_코어_alimtalk_템플릿이_없어도_카카오_내용으로_발송한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 알림톡 본문은 카카오에서 오므로, 코어 alimtalk 템플릿(SMS 대체용)이 없어도 발송돼야 한다. + // 대체발송 OFF 이면 코어 템플릿을 아예 조회하지 않는다. + $this->makeDriver(null, $this->binding(fallback: false))->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + } + public function test_회원은_mobile로_연결된_템플릿코드로_발송한다(): void { Bus::fake(); @@ -183,42 +229,64 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->assertSame('01099990000', $builder->calls[0]['to']); } - public function test_본문_변수를_알림톡_형식으로_변환한다(): void + public function test_카카오_본문의_변수를_알림_data로_치환해_발송한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); $builder = $this->spyBuilder(); + // 알림톡 본문은 카카오 승인 템플릿(#{var})에서 오고, 발송 시 알림 data 로 치환된다. $this->makeDriver( - $this->fakeTemplate(['subject' => '', 'body' => '{name} 님 {order_number} 주문 완료']), + $this->fakeTemplate(), $this->binding(), $builder, - )->send($member, $this->notification()); + kakao: ['templateCode' => 'TW_1234', 'templateContent' => '#{name}님 #{order_number} 주문 완료'], + )->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1'])); $this->assertSame( - '#{name} 님 #{order_number} 주문 완료', + '김철수님 A1 주문 완료', $builder->calls[0]['message'], - '코어 {var} 형식을 알림톡 #{var} 형식으로 치환해야 한다.', + '카카오 본문의 #{var} 를 알림 data 로 치환해야 한다.', ); } - public function test_이미_알림톡_형식인_변수는_중복_변환하지_않는다(): void + public function test_카카오_버튼을_발송_extra로_전달한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - $builder = $this->spyBuilder(); + + // 버튼 URL 변수까지 치환돼 payload button 에 실려야 한다(회귀: 이전엔 버튼 자체가 누락). + $builder = new class extends MessagePayloadBuilder + { + /** @var array> */ + public array $extras = []; + + public function __construct() {} + + public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array + { + $this->extras[] = $extra; + + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => array_merge(['message' => $message], $extra)]]; + } + }; $this->makeDriver( - $this->fakeTemplate(['subject' => '', 'body' => '#{name} 님 {order_number}']), + $this->fakeTemplate(), $this->binding(), $builder, - )->send($member, $this->notification()); + kakao: [ + 'templateCode' => 'TW_1234', + 'templateContent' => '#{name}님 주문 완료', + 'buttons' => [ + ['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/orders/#{order_number}'], + ], + ], + )->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1'])); - $this->assertSame( - '#{name} 님 #{order_number}', - $builder->calls[0]['message'], - '이미 #{var} 인 변수는 ##{var} 로 이중 변환되면 안 된다.', - ); + $button = $builder->extras[0]['button'][0]; + $this->assertSame('WL', $button['type']); + $this->assertSame('https://m.shop/orders/A1', $button['url_mobile'], '버튼 URL 변수도 치환돼 발송돼야 한다.'); } public function test_대체발송_o_n이면_payload에_sms_resend가_병합된다(): void @@ -305,4 +373,17 @@ class AlimtalkChannelDriverTest extends PluginTestCase Bus::assertNotDispatched(SendMessageJob::class); } + + public function test_정상_조건에서_발송하고_이력을_생성한다(): void + { + // 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다. + // 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다. + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 1); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php new file mode 100644 index 00000000..4e8e4c03 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php @@ -0,0 +1,169 @@ +mapper()->map( + ['templateContent' => '#{name}님 주문 #{order_no} 완료'], + ['name' => '홍길동', 'order_no' => 'A123'], + ); + + $this->assertSame('홍길동님 주문 A123 완료', $result['message']); + } + + public function test_웹링크_버튼을_발송형식으로_변환하고_url변수를_치환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + [ + 'name' => '주문조회', + 'linkType' => 'WL', + 'linkMo' => 'https://m.shop/orders/#{order_no}', + 'linkPc' => 'https://shop/orders/#{order_no}', + ], + ], + ], + ['order_no' => 'A123'], + ); + + $button = $result['extra']['button'][0]; + $this->assertSame('주문조회', $button['name']); + $this->assertSame('WL', $button['type']); + $this->assertSame('https://m.shop/orders/A123', $button['url_mobile']); + $this->assertSame('https://shop/orders/A123', $button['url_pc']); + // 카카오 필드명(linkType/linkMo)은 발송 payload 에 남지 않아야 한다. + $this->assertArrayNotHasKey('linkType', $button); + $this->assertArrayNotHasKey('linkMo', $button); + } + + public function test_앱링크_전화_플러그인_버튼_필드를_매핑한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '앱', 'linkType' => 'AL', 'linkAnd' => 'myapp://a', 'linkIos' => 'myapp://i'], + ['name' => '전화', 'linkType' => 'TN', 'telNumber' => '1588-0000'], + ['name' => '플러그인', 'linkType' => 'P1', 'pluginId' => 'PLUG_1'], + ], + ], + [], + ); + + $buttons = $result['extra']['button']; + $this->assertSame('myapp://a', $buttons[0]['scheme_android']); + $this->assertSame('myapp://i', $buttons[0]['scheme_ios']); + $this->assertSame('1588-0000', $buttons[1]['tel_number']); + $this->assertSame('PLUG_1', $buttons[2]['plugin_id']); + } + + public function test_quick_replies를_quickreply로_변환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'quickReplies' => [ + ['name' => '바로가기', 'linkType' => 'WL', 'linkMo' => 'https://m.shop'], + ], + ], + [], + ); + + $qr = $result['extra']['quickreply'][0]; + $this->assertSame('바로가기', $qr['name']); + $this->assertSame('WL', $qr['type']); + $this->assertSame('https://m.shop', $qr['url_mobile']); + } + + public function test_title_header를_치환해_매핑한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateTitle' => '#{name}님', + 'templateHeader' => '주문 안내', + ], + ['name' => '홍길동'], + ); + + $this->assertSame('홍길동님', $result['extra']['title']); + $this->assertSame('주문 안내', $result['extra']['header']); + } + + public function test_item과_itemhighlight를_매핑하고_치환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateItem' => [ + 'list' => [ + ['title' => '상품', 'description' => '#{product}'], + ], + 'summary' => ['title' => '합계', 'description' => '#{total}원'], + ], + 'templateItemHighlight' => ['title' => '#{name}님', 'description' => '주문완료'], + ], + ['product' => '티셔츠', 'total' => '20,000', 'name' => '홍길동'], + ); + + $this->assertSame('티셔츠', $result['extra']['item']['list'][0]['description']); + $this->assertSame('20,000원', $result['extra']['item']['summary']['description']); + $this->assertSame('홍길동님', $result['extra']['itemhighlight']['title']); + } + + public function test_대표링크를_link로_변환하고_치환한다(): void + { + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateRepresentLink' => [ + 'linkMo' => 'https://m.shop/#{id}', + 'linkPc' => 'https://shop/#{id}', + ], + ], + ['id' => 'X1'], + ); + + $this->assertSame('https://m.shop/X1', $result['extra']['link']['url_mobile']); + $this->assertSame('https://shop/X1', $result['extra']['link']['url_pc']); + } + + public function test_부재_필드는_extra에_넣지_않는다(): void + { + // 본문만 있는 단순 템플릿 → extra 는 비어야 한다. + $result = $this->mapper()->map(['templateContent' => '본문'], []); + + $this->assertSame('본문', $result['message']); + $this->assertSame([], $result['extra']); + } + + public function test_빈_버튼배열은_extra에_button을_만들지_않는다(): void + { + $result = $this->mapper()->map( + ['templateContent' => '본문', 'buttons' => []], + [], + ); + + $this->assertArrayNotHasKey('button', $result['extra']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php index f04c4a83..036de8ca 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php @@ -100,6 +100,33 @@ class AlimtalkTemplateServiceTest extends PluginTestCase $this->assertSame('green', $detail['status_badge']['variant']); } + public function test_발신프로필은_data_success_배열을_반환한다(): void + { + // 규격(5.발신프로필관리): /v3/kakao/profile/use 응답 data 는 {success:[...], fail:[...]} + // 2단 봉투다. 실제 발신프로필 목록은 data.success 안에 있으므로 그 배열을 반환해야 한다. + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => [ + 'success' => [ + ['senderKey' => 'SK_40', 'name' => '테스트채널', 'status' => 'A'], + ], + 'fail' => [], + ], + ], 200), + ]); + + $profiles = $this->service()->senderProfiles(); + + // success 배열이 그대로 반환되어야 한다(껍데기 {success,fail} 가 아님). + $this->assertCount(1, $profiles); + $this->assertSame('SK_40', $profiles[0]['senderKey']); + $this->assertSame('테스트채널', $profiles[0]['name']); + // 회귀 방지: data 통째 반환 시 노출되던 success/fail 키가 없어야 한다. + $this->assertArrayNotHasKey('success', $profiles); + $this->assertArrayNotHasKey('fail', $profiles); + } + public function test_발신프로필_키_미설정시_예외(): void { $this->expectException(BizppurioApiException::class); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php new file mode 100644 index 00000000..c84736e3 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php @@ -0,0 +1,174 @@ +flush(); + } + + /** + * @param array $settings + */ + private function resolver(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): KakaoTemplateContentResolver + { + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings); + // template_cache_minutes 조회 (기본 60분 → 내부에서 ×60 초) + $pluginSettings->shouldReceive('get') + ->with(self::IDENTIFIER, 'template_cache_minutes', Mockery::any()) + ->andReturnUsing(fn ($id, $key, $default) => $settings['template_cache_minutes'] ?? $default); + + $kakao = new BizppurioKakaoApiClient($pluginSettings); + $templates = new AlimtalkTemplateService($kakao, $pluginSettings); + + // 실제 확장 캐시 드라이버 주입 (contextual binding 과 동일 계약) + $cache = app(CacheInterface::class); + + return new KakaoTemplateContentResolver($templates, $cache, $pluginSettings); + } + + /** + * 상세조회 응답의 원본 카카오 필드(templateContent/buttons)를 그대로 반환한다. + */ + public function test_템플릿_내용을_조회해_반환한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => [ + 'templateCode' => 'TW_1', + 'templateContent' => '#{name}님 주문 완료', + 'buttons' => [ + ['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/#{order}'], + ], + 'inspectionStatus' => 'APR', + 'status' => 'A', + ], + ], 200), + ]); + + $content = $this->resolver()->resolve('TW_1'); + + $this->assertNotNull($content); + $this->assertSame('#{name}님 주문 완료', $content['templateContent']); + $this->assertSame('WL', $content['buttons'][0]['linkType']); + } + + /** + * 같은 template_code 를 두 번 조회하면 두 번째는 캐시 히트 — kapi 호출은 1회뿐. + */ + public function test_두번째_조회는_캐시_히트로_kapi를_다시_부르지_않는다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'], + ], 200), + ]); + + $resolver = $this->resolver(); + $resolver->resolve('TW_1'); + $resolver->resolve('TW_1'); + + Http::assertSentCount(1); + } + + /** + * TTL=0 이면 캐시를 우회 — 매 조회마다 kapi 를 부른다(항상 최신). + */ + public function test_ttl이_0이면_캐시를_우회해_매번_조회한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response([ + 'code' => '200', + 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'], + ], 200), + ]); + + $resolver = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]); + $resolver->resolve('TW_1'); + $resolver->resolve('TW_1'); + + Http::assertSentCount(2); + } + + /** + * 조회 실패(고아 템플릿·장애)는 null 을 반환한다(호출측이 발송 skip). + */ + public function test_조회_실패시_null을_반환한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '7315', 'message' => '템플릿 없음'], 200), + ]); + + $this->assertNull($this->resolver()->resolve('GONE')); + } + + /** + * rate limit(HTTP 429)이면 짧게 재시도한 뒤 성공하면 내용을 반환한다(조회 폭주 완화). + */ + public function test_rate_limit_429는_재시도해_성공하면_반환한다(): void + { + // 첫 응답 429 → 재시도 → 두 번째 성공. + Http::fakeSequence('kapi.ppurio.com/*') + ->push(['code' => '5002', 'description' => 'too many requests'], 429) + ->push([ + 'code' => '200', + 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'], + ], 200); + + $content = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]) + ->resolve('TW_1'); + + $this->assertNotNull($content); + $this->assertSame('본문', $content['templateContent']); + Http::assertSentCount(2); + } + + /** + * 재시도해도 계속 429면 null 을 반환한다(무한 재시도 방지). + */ + public function test_rate_limit이_지속되면_null을_반환한다(): void + { + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '5002', 'description' => 'too many requests'], 429), + ]); + + $this->assertNull( + $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]) + ->resolve('TW_1'), + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php index 38d099df..5bbd6efd 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php @@ -5,6 +5,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services; use Mockery; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository; use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; +use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver; use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -30,14 +31,27 @@ class NotificationBindingServiceTest extends PluginTestCase return $service; } - private function makeService(array $templates = []): NotificationBindingService + private function makeService(array $templates = [], ?KakaoTemplateContentResolver $kakaoContent = null): NotificationBindingService { return new NotificationBindingService( new BizppurioNotificationBindingRepository, $this->fakeTemplateService($templates), + $kakaoContent ?? $this->fakeKakaoContent(), ); } + /** + * clearMany 호출 인자를 기록하는 KakaoTemplateContentResolver mock 을 만듭니다. + */ + private function fakeKakaoContent(): KakaoTemplateContentResolver + { + $resolver = Mockery::mock(KakaoTemplateContentResolver::class); + $resolver->shouldReceive('clearMany') + ->andReturnUsing(fn (array $codes) => count(array_unique(array_filter($codes)))); + + return $resolver; + } + public function test_all은_알림톡_연동을_type_키_맵으로_반환한다(): void { (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ @@ -60,6 +74,77 @@ class NotificationBindingServiceTest extends PluginTestCase $this->assertSame([], $this->makeService()->all()); } + public function test_all_withAvailability는_승인목록에_있는_연동을_사용가능으로_표시한다(): void + { + (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_LIVE', + 'template_name' => '살아있음', + 'is_active' => true, + ]); + + // 승인 목록에 TW_LIVE 가 있음 → 사용 가능(is_unavailable=false) + $map = $this->makeService([ + ['templateCode' => 'TW_LIVE', 'templateName' => '살아있음', 'serviceStatus' => 'ACT'], + ])->all(withAvailability: true); + + $this->assertFalse($map['order_confirmed']['is_unavailable']); + } + + public function test_all_withAvailability는_승인목록에_없는_연동을_사용불가로_표시한다(): void + { + (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_GONE', + 'template_name' => '삭제됨', + 'is_active' => true, + ]); + + // 승인 목록에 TW_GONE 이 없음(삭제·차단·미승인) → 사용 불가(is_unavailable=true) + $map = $this->makeService([ + ['templateCode' => 'TW_OTHER', 'templateName' => '다른템플릿', 'serviceStatus' => 'ACT'], + ])->all(withAvailability: true); + + $this->assertTrue($map['order_confirmed']['is_unavailable']); + } + + public function test_all_withAvailability는_카카오_조회_실패시_소실판정을_생략한다(): void + { + (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_1234', + 'template_name' => '주문완료', + 'is_active' => true, + ]); + + // 카카오 승인 목록 조회가 실패(자격증명 미설정·장애)하면 판정을 건너뛴다 — + // 살아있는 연동이 일시 장애로 "사용 불가"로 오탐되지 않게(is_unavailable 필드 미부여). + $throwingTemplates = Mockery::mock(AlimtalkTemplateService::class); + $throwingTemplates->shouldReceive('list') + ->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정')); + + $service = new NotificationBindingService( + new BizppurioNotificationBindingRepository, + $throwingTemplates, + $this->fakeKakaoContent(), + ); + + $map = $service->all(withAvailability: true); + + $this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed'], '조회 실패 시 소실 판정을 생략해야 한다.'); + } + + public function test_all은_기본값에서_소실판정을_하지_않는다(): void + { + (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_1234', + 'template_name' => '주문완료', + 'is_active' => true, + ]); + + // store(저장) 경로처럼 프리필만 필요하면 카카오 대조를 생략한다(is_unavailable 필드 없음). + $map = $this->makeService()->all(); + + $this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed']); + } + public function test_승인_상태_템플릿만_연결_후보로_노출한다(): void { $service = $this->makeService([ @@ -141,4 +226,34 @@ class NotificationBindingServiceTest extends PluginTestCase 'channel' => 'alimtalk', ]); } + + public function test_캐시초기화는_연결된_모든_template_code를_resolver에_넘긴다(): void + { + // 서로 다른 알림에 두 템플릿을 연결. + $repo = new BizppurioNotificationBindingRepository; + $repo->upsert('order_confirmed', 'alimtalk', ['template_code' => 'TW_1', 'template_name' => 'A', 'is_active' => true]); + $repo->upsert('welcome', 'alimtalk', ['template_code' => 'TW_2', 'template_name' => 'B', 'is_active' => true]); + + // resolver 가 실제로 받은 코드 목록을 포착. + $received = null; + $resolver = Mockery::mock(KakaoTemplateContentResolver::class); + $resolver->shouldReceive('clearMany') + ->once() + ->andReturnUsing(function (array $codes) use (&$received) { + $received = $codes; + + return count($codes); + }); + + $cleared = $this->makeService([], $resolver)->clearTemplateContentCache(); + + $this->assertSame(2, $cleared); + $this->assertContains('TW_1', $received); + $this->assertContains('TW_2', $received); + } + + public function test_캐시초기화는_연동이_없으면_0을_반환한다(): void + { + $this->assertSame(0, $this->makeService()->clearTemplateContentCache()); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php index 6da7f577..24a5e517 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -46,14 +46,26 @@ class ResultCodeResolverTest extends PluginTestCase /** * 일시오류 코드는 Retry. + * + * 공통(5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류(7306 카카오 시스템오류· + * 7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 재시도 대상이다. 7305(성공 불확실)는 + * 이미 발송됐을 수 있어 중복발송 위험이 있으므로 재시도 대상에서 제외한다. */ public function test_retryable_codes_categorized_as_retry(): void { - foreach (['5003', '5004', '5005', '9000', '3011', '3013'] as $code) { + foreach (['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) { $this->assertSame(ResultCategory::Retry, $this->resolver->categorize($code), "코드 {$code}"); } } + /** + * 7305(성공 불확실)는 중복발송 위험으로 재시도 대상이 아니다(영구실패로 분류). + */ + public function test_success_uncertain_7305_is_not_retryable(): void + { + $this->assertNotSame(ResultCategory::Retry, $this->resolver->categorize('7305')); + } + /** * 위 분류에 없는 코드는 영구 실패. */ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml index 9d071d93..62585477 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml @@ -2,7 +2,10 @@ # Phase 6(재설계)는 알림톡 실발송 위임(백엔드) + 시딩 필터 훅 template 증강 + 코어 편집 모달 # 전용 칸(즉시 저장) + 상태 배너 overlay 이다. axes cross product 의 런타임 효과는 계층별로 # 전수 가드된다: -# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/변수치환/대체발송/전화번호/이력. +# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/카카오 내용 발송/버튼 extra/대체발송/전화번호/이력/플러그인 활성. +# - 카카오 내용 조회·캐시(kakao_content/content_cache 축): KakaoTemplateContentResolverTest — 조회/캐시 히트/TTL=0 우회/429 재시도/실패 null. +# - 카카오→발송 변환(kakao_elements 축): AlimtalkPayloadMapperTest — 본문/버튼/바로연결/title·header·item·highlight·link 변환 + #{var} 치환 + 부재 필드 생략. +# - 캐시 수동 초기화 엔드포인트·권한: AlimtalkTemplateControllerTest + NotificationBindingServiceTest. # - template 증강: SeedChannelTemplatesListenerTest — 회원판정/기본문구/채널추가/멱등. # - 연동 즉시저장·조회·권한: NotificationBindingServiceTest + NotificationBindingEndpointTest. # - 채널 메타(is_test_mode): RegisterNotificationChannelsListenerTest. @@ -24,9 +27,16 @@ description: | 핵심 동작: - SeedChannelTemplatesListener: 코어/모듈 시딩 필터 훅(seed.*.notifications.translations)에 편승해 회원 알림에 sms·alimtalk template 증강(cleanupStaleTemplates 에도 유지). - - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. - - 변수 치환: 코어 {var} → 알림톡 #{var}(D8, 매핑 UI 없음). + - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. 플러그인 비활성 시 차단. + - 카카오 내용 반영(B안): 발송 본문·버튼·요소를 카카오 승인 템플릿(templateContent/buttons/ + quickReplies/title/header/item/itemHighlight/representLink)에서 상세조회로 가져와 채운다. + 비즈뿌리오 발송 API 는 완성본을 요구하므로 templatecode 만으로는 안 된다. + · KakaoTemplateContentResolver: 상세조회 + template_code 단위 캐시(기본 1h, TTL=0 끔, + 429/5002 rate limit 시 1회 재시도, 실패 null → skip). + · AlimtalkPayloadMapper: 카카오 필드 → 발송 형식 변환(linkMo→url_mobile 등) + #{var} 치환. + · 캐시 수동 초기화 엔드포인트/버튼(manage 권한): 카카오 변경 즉시 반영. - 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합. + 대체 SMS 본문은 카카오와 무관하게 코어 alimtalk 템플릿에서 렌더(빈 본문이면 미병합). - 행 연결 UI(extension_point notification_definition_row_footer): 알림톡 탭의 각 알림 행 하단에 연결 상태 줄(템플릿명·SMS대체) + [연결/변경] 버튼. channel==='alimtalk' 일 때만. - 연결 모달(overlay modals, 우리 소유): 코어 편집 모달과 분리. 안내 카드 + 연결 템플릿 @@ -43,25 +53,46 @@ axes: member_facing: [member_target, admin_only] banner_state: [not_ready, test_mode, normal] e2e_binding_action: [connect, change, unbind] + # B안 — 카카오 내용 반영·캐시 + kakao_content: [fetched, orphan_or_error, rate_limited] + kakao_elements: [body_only, with_button, with_quickreply, with_title, with_item, with_link] + content_cache: [hit, miss, ttl_zero, manual_cleared] exclusions: - { binding_state: unbound, fallback_sms: on, reason: "미연결 알림은 대체발송 설정 자체가 없음" } - { member_facing: admin_only, binding_state: bound, reason: "관리자 전용 알림은 증강 대상 아님 — 연동 불가" } - { recipient: guest, e2e_binding_action: connect, reason: "연동 저장 UI 는 관리자 화면 — 수신자(member/guest)는 발송 축, UI 축과 무관" } + - { kakao_content: orphan_or_error, kakao_elements: with_button, reason: "조회 실패 시 내용 자체가 없어 요소 축 무의미 — skip 으로 수렴" } + - { kakao_content: rate_limited, kakao_elements: with_button, reason: "rate limit 은 조회 단계 — 재시도 소진 시 null → skip, 요소 축 무의미" } + - { content_cache: manual_cleared, kakao_elements: with_item, reason: "캐시 초기화는 조회 시점 축 — 요소 종류와 직교, 대표 조합만 커버" } effects: # 발송 위임 (AlimtalkChannelDriver) - alimtalk_dispatched_with_bound_template_code - dispatch_skipped_when_no_binding - - dispatch_skipped_when_no_alimtalk_template + - dispatch_skipped_when_plugin_inactive - dispatch_skipped_when_no_phone - - core_variables_converted_to_alimtalk_hash_form - - already_hash_form_variable_not_double_converted - member_phone_resolved_from_mobile - guest_phone_resolved_from_data_recipient_phone - fallback_on_merges_sms_resend_recontent - fallback_off_has_no_resend + - fallback_sms_body_from_core_template_not_kakao + - fallback_skipped_when_core_template_missing - pending_dispatch_created_with_alimtalk_channel + # 카카오 내용 반영 (KakaoTemplateContentResolver + AlimtalkPayloadMapper) — B안 + - alimtalk_body_from_kakao_template_content + - dispatch_skipped_when_kakao_content_unavailable + - dispatch_can_send_without_core_alimtalk_template + - kakao_variables_substituted_from_notification_data + - kakao_buttons_mapped_to_send_form_with_url_substitution + - kakao_quickreplies_mapped_to_send_form + - kakao_title_header_item_highlight_link_mapped + - absent_kakao_fields_omitted_from_payload + - content_cache_hit_skips_kapi_call + - content_cache_ttl_zero_bypasses_cache + - content_cache_rate_limit_retried_then_null + - manual_cache_clear_forgets_bound_template_codes + - manual_cache_clear_requires_manage_permission # template 증강 (SeedChannelTemplatesListener) - member_facing_notification_gets_sms_and_alimtalk_channels - admin_only_notification_not_augmented @@ -101,10 +132,14 @@ effects: test_files: - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx audit: status: codified \ No newline at end of file From a8a85ac0cf5e8fe7708da556fb776d06847fe917 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 24 Jul 2026 10:56:31 +0900 Subject: [PATCH 18/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EA=B2=80?= =?UTF-8?q?=EC=88=98/=EC=9A=B4=EC=98=81=20=EB=B3=84=EB=8F=84=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=95=88=EB=82=B4=20+=20=EC=82=AC=EC=A0=84=20?= =?UTF-8?q?=EC=A4=80=EB=B9=84=20UI=202=EB=8B=A8=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비즈뿌리오는 검수/운영을 별도 계정으로 운영하도록 권장하나 본 플러그인은 계정을 1벌만 보관하므로, 관리자가 이를 인지하도록 검수 모드 카드에 안내를 상시 노출한다. 검수 모드 안내의 "(개발)" 표현도 제거했다. 또한 사전 준비 안내가 세로로 길어 보이는 문제를 개선해, 문자·카카오를 넓은 화면에서 2단 카드 + 채널 아이콘으로 정리했다(모바일 1단 유지). --- .../sirsoft-message_bizppurio/CHANGELOG.md | 2 ++ .../layouts/plugin_settings.test.tsx | 30 +++++++++++++++++++ .../resources/lang/en.json | 3 +- .../resources/lang/ko.json | 3 +- .../layouts/admin/plugin_settings.json | 22 +++++++++----- .../tests/scenarios/plugin-settings.yaml | 1 + 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index c8d9ac81..b8e87d64 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -30,3 +30,5 @@ - 알림톡 발송이 일시적인 오류(카카오 시스템 오류·처리 지연·타임아웃 등)로 실패한 경우 자동으로 다시 시도합니다. - 알림에 연결한 알림톡 템플릿이 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요" 표시가 나타납니다. 예전에는 연결이 끊긴 뒤에도 정상 연결처럼 보여 발송 실패 후에야 알 수 있었지만, 이제 화면에서 바로 확인하고 다시 연결할 수 있습니다. (카카오 조회가 일시적으로 안 되는 경우에는 잘못된 경고가 뜨지 않도록 표시를 보류합니다.) - 알림톡 연결 창에서 자격증명 오류 등으로 승인 템플릿 목록을 불러오지 못한 경우와, 승인된 템플릿이 실제로 없는 경우를 구분해 안내합니다. 불러오기에 실패하면 설정을 확인하라는 안내가, 실제로 없으면 템플릿을 먼저 등록·검수하라는 안내가 표시됩니다. +- 검수 모드 설정에 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장한다는 안내를 함께 표시합니다. +- 사용 전 준비 안내를 문자(SMS/LMS)와 카카오 알림톡 두 갈래로 나눠 넓은 화면에서 좌우 두 칸으로 보여주고, 각 항목에 채널 아이콘을 더해 한눈에 구분되도록 정리했습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx index 5d7f071e..ac9954aa 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx @@ -79,6 +79,27 @@ describe('plugin_settings.json — 섹션', () => { expect(introIdx).toBeLessThan(smsIdx); }); + it('채널별 준비 목록은 PC(lg 이상) 2단, 모바일 1단 그리드로 배치된다', () => { + // 문자·카카오 블록을 감싼 컨테이너가 grid grid-cols-1 lg:grid-cols-2 여야 한다. + // sms_label 을 담은 블록의 부모(구분선 아래 컨테이너)에서 grid 클래스를 확인. + const notice = findById(root, 'preparation_notice'); + const raw = JSON.stringify(notice); + // 세로 1단 고정(flex-col) 이 아니라 반응형 grid 여야 한다. + expect(raw).toContain('grid-cols-1'); + expect(raw).toContain('lg:grid-cols-2'); + }); + + it('문자·카카오 준비 블록은 카드(배경+테두리)로 감싸고 채널 아이콘을 라벨에 붙인다', () => { + const notice = findById(root, 'preparation_notice'); + const raw = JSON.stringify(notice); + // 각 열이 카드로 감싸짐 (다크모드 쌍 포함 solid 배경) + expect(raw).toContain('bg-blue-100'); + expect(raw).toContain('dark:bg-blue-900'); + // 채널별 아이콘 (문자=envelope, 카카오=comment-dots) + expect(raw).toContain('"envelope"'); + expect(raw).toContain('"comment-dots"'); + }); + it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => { const card = findById(root, 'test_mode_card'); expect(card).toBeTruthy(); @@ -87,6 +108,15 @@ describe('plugin_settings.json — 섹션', () => { expect(raw).toContain('is_test_mode'); }); + it('검수 모드 카드에 검수/운영 계정 분리 권장 안내가 상시 노출된다', () => { + // 검수 켜짐/꺼짐과 무관하게(if 조건 없이) 카드 안에 계정 분리 권장 문구가 있어야 한다. + const card = findById(root, 'test_mode_card') as { if?: string } | null; + expect(card).toBeTruthy(); + expect(card?.if).toBeUndefined(); // 카드 자체가 조건부가 아님 → 상시 노출 + const raw = JSON.stringify(card); + expect(raw).toContain('settings.test_mode.account_notice'); + }); + it('운영 모드(검수 off) 경고 박스가 조건부로 존재한다', () => { const warning = findById(root, 'live_mode_warning'); expect(warning).toBeTruthy(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index 0cff3388..db034ab3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -10,7 +10,8 @@ "save_failed": "Failed to save settings.", "test_mode": { "label": "Test Mode", - "hint": "In test mode, messages are sent to the Bizppurio inspection (dev) domain. Turn it off to send through the production domain." + "hint": "In test mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.", + "account_notice": "We recommend using separate Bizppurio accounts for inspection and production." }, "live_mode_warning_title": "Sending in production", "live_mode_warning_body": "Test mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep test mode on while inspecting.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 8fb50616..a46b0e9c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -10,7 +10,8 @@ "save_failed": "설정 저장에 실패했습니다.", "test_mode": { "label": "검수 모드", - "hint": "검수 모드에서는 비즈뿌리오 검수(개발) 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다." + "hint": "검수 모드에서는 비즈뿌리오 검수 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다.", + "account_notice": "검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다." }, "live_mode_warning_title": "운영 환경으로 발송됩니다", "live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index 6510891f..377df1bc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -182,25 +182,25 @@ ] }, { - "comment": "구분선 아래 — 문자·카카오 채널별 사전 준비 목록", + "comment": "구분선 아래 — 문자·카카오 채널별 사전 준비 목록 (PC lg 이상 2단, 모바일 1단)", "type": "basic", "name": "Div", - "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 flex flex-col gap-4" }, + "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 grid grid-cols-1 lg:grid-cols-2 gap-4" }, "children": [ { - "comment": "문자(SMS/LMS) — 라벨 + 발신번호 등록 1줄", + "comment": "문자(SMS/LMS) — 카드 + 아이콘 라벨 + 발신번호 등록 1줄", "type": "basic", "name": "Div", - "props": { "className": "space-y-1" }, + "props": { "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" }, + { "type": "basic", "name": "P", "props": { "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" }, "children": [ { "type": "basic", "name": "Icon", "props": { "name": "envelope", "size": "sm", "className": "text-blue-500 dark:text-blue-400" } }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" } ] }, { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] } ] }, { - "comment": "카카오 알림톡 — 라벨 + 채널/템플릿/API키 3줄", + "comment": "카카오 알림톡 — 카드 + 아이콘 라벨 + 채널/템플릿/API키 3줄", "type": "basic", "name": "Div", - "props": { "className": "space-y-1" }, + "props": { "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" }, "children": [ - { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" }, + { "type": "basic", "name": "P", "props": { "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" }, "children": [ { "type": "basic", "name": "Icon", "props": { "name": "comment-dots", "size": "sm", "className": "text-blue-500 dark:text-blue-400" } }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" } ] }, { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] }, { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] }, { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] } @@ -304,6 +304,12 @@ "name": "P", "props": { "className": "form-hint" }, "text": "$t:sirsoft-message_bizppurio.settings.test_mode.hint" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-message_bizppurio.settings.test_mode.account_notice" } ] }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml index d1fc3093..7e6b2b3c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml @@ -41,6 +41,7 @@ effects: # 섹션 구조 - info_panel_test_mode_api_sending_report_sections_present - test_mode_card_has_is_test_mode_toggle + - test_mode_card_shows_separate_account_notice_always - live_mode_warning_shown_when_test_mode_off # 입력 필드 - five_input_fields_bound_by_name From 4e77d259267441d7a9383ba646dabfc79b6049d7 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 24 Jul 2026 16:27:33 +0900 Subject: [PATCH 19/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EA=B2=80?= =?UTF-8?q?=EC=88=98=20=EB=AA=A8=EB=93=9C=20=EB=9D=BC=EB=B2=A8=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20+=20=EB=B0=9C=EC=86=A1=20payload=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20+=20=EC=95=8C=EB=A6=BC=ED=86=A1=20URL=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=EC=BD=9C=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발송 이력 화면의 "테스트 모드" 표기를 "검수 모드"로 통일하고, 검수 모드 발송 건은 상태 라벨 대신 "검수" 라벨로 표시한다. 설정 미완료 상태에서도 검수 모드 안내가 함께 표시되도록 배너 노출 조건을 수정한다. 발송 요청 payload가 어디에도 저장되지 않아 실패 원인을 사후 재구성할 수 없던 문제를 해결한다. bizppurio_dispatches.request_payload 컬럼을 신설해 발송 시점(성공/실패 무관)에 실제 비즈뿌리오 전송 payload를 저장하되, 다른 컬럼과 중복되는 개인식별 정보(수신번호·치환된 본문 텍스트)는 제외하고 저장한다. 카카오 알림톡 버튼 URL과 완전한 URL 변수(action_url 등, 항상 프로토콜 포함)가 결합될 때 프로토콜이 중복(https://https://...)되어 잘못된 링크로 발송되던 문제를 방지한다. 플러그인 제거 후 재설치 시 sms·alimtalk 채널 템플릿이 재생성되지 않던 문제를 해결한다. 리스너 등록 이력 캐시가 재활성화 후에도 남아있어 재등록이 조용히 스킵되던 것으로, 코어 수정 없이 플러그인 내부에서 리플렉션으로 우회한다. --- .../sirsoft-message_bizppurio/CHANGELOG.md | 9 +- ...0001_create_bizppurio_dispatches_table.php | 2 + ..._bizppurio_notification_bindings_table.php | 2 +- .../docs/api/notification-bindings.md | 2 +- .../sirsoft-message_bizppurio/plugin.php | 85 ++++++++++- .../extensions/notification_log_result.json | 8 +- .../extensions/notification_tab_core.json | 4 +- .../layouts/notification_log_result.test.tsx | 43 ++++++ .../layouts/notification_tab_binding.test.tsx | 18 +++ .../resources/lang/en.json | 11 +- .../resources/lang/ko.json | 3 +- .../StoreNotificationBindingRequest.php | 2 +- .../RegisterNotificationChannelsListener.php | 4 +- .../src/Models/BizppurioDispatch.php | 6 + .../src/Services/AlimtalkChannelDriver.php | 20 +++ .../src/Services/AlimtalkPayloadMapper.php | 40 ++++- .../src/Services/DispatchResultService.php | 4 +- .../src/Services/MessagePayloadBuilder.php | 28 ++++ .../src/Services/SmsChannelDriver.php | 20 +++ .../ChannelTemplateCleanupOnUninstallTest.php | 144 ++++++++++++++++++ .../tests/PluginTestCase.php | 43 ++++++ .../Services/AlimtalkChannelDriverTest.php | 69 +++++++++ .../Services/AlimtalkPayloadMapperTest.php | 106 +++++++++++++ .../Services/DispatchResultServiceTest.php | 18 +++ .../Services/MessagePayloadBuilderTest.php | 71 +++++++++ .../Unit/Services/SmsChannelDriverTest.php | 70 ++++++++- 26 files changed, 808 insertions(+), 24 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index b8e87d64..17684dd5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -25,10 +25,17 @@ - 목록 상단에 배지 의미(발송가능·검수중·미검수·반려)와 함께, 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 한다는 안내와 콘솔 바로가기를 제공합니다. - 환경설정 탭에 카카오 알림톡·문자(SMS/LMS) 사용 전 준비 사항(비즈뿌리오 가입·API 승인·발신프로필 등록·발신번호 사전 등록)을 안내하는 박스를 추가했습니다. - 템플릿 목록·상세 조회가 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 발신프로필 미설정 등)를 화면에 그대로 표시합니다. -- 알림에 연결한 카카오 알림톡 템플릿을 실제로 발송합니다. 카카오에 등록·승인된 템플릿의 본문뿐 아니라 버튼·바로연결·강조표기·아이템리스트·대표링크까지 그대로 발송에 반영하며, 각 항목의 변수는 발송 시점의 실제 데이터로 채워집니다. 버튼이나 링크가 포함된 템플릿도 정상적으로 발송됩니다. +- 알림에 연결한 카카오 알림톡 템플릿을 실제로 발송합니다. 카카오에 등록·승인된 템플릿의 본문뿐 아니라 버튼·바로연결·강조표기·아이템리스트·대표링크까지 그대로 발송에 반영하며, 각 항목의 변수는 발송 시점의 실제 데이터로 채워집니다. 버튼이나 링크가 포함된 템플릿도 정상적으로 발송됩니다. 버튼 URL이 로그인·비밀번호 재설정 등 완전한 주소 변수와 결합되는 경우에도 주소가 겹치지 않도록 처리합니다. - 환경설정에 "알림톡 내용 캐시 시간(분)"과 "캐시 초기화" 버튼을 추가했습니다. 카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용하며(기본 60분, 0으로 두면 발송할 때마다 최신 내용을 가져옵니다), 카카오에서 템플릿을 방금 수정한 경우 [캐시 초기화]로 다음 발송부터 최신 내용이 즉시 반영되도록 할 수 있습니다. - 알림톡 발송이 일시적인 오류(카카오 시스템 오류·처리 지연·타임아웃 등)로 실패한 경우 자동으로 다시 시도합니다. - 알림에 연결한 알림톡 템플릿이 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요" 표시가 나타납니다. 예전에는 연결이 끊긴 뒤에도 정상 연결처럼 보여 발송 실패 후에야 알 수 있었지만, 이제 화면에서 바로 확인하고 다시 연결할 수 있습니다. (카카오 조회가 일시적으로 안 되는 경우에는 잘못된 경고가 뜨지 않도록 표시를 보류합니다.) - 알림톡 연결 창에서 자격증명 오류 등으로 승인 템플릿 목록을 불러오지 못한 경우와, 승인된 템플릿이 실제로 없는 경우를 구분해 안내합니다. 불러오기에 실패하면 설정을 확인하라는 안내가, 실제로 없으면 템플릿을 먼저 등록·검수하라는 안내가 표시됩니다. - 검수 모드 설정에 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장한다는 안내를 함께 표시합니다. - 사용 전 준비 안내를 문자(SMS/LMS)와 카카오 알림톡 두 갈래로 나눠 넓은 화면에서 좌우 두 칸으로 보여주고, 각 항목에 채널 아이콘을 더해 한눈에 구분되도록 정리했습니다. +- 검수 모드로 발송된 문자·알림톡은 발송 이력 화면에 "발송중" 대신 "검수" 라벨로 표시되어, 실제 장애와 검수 도메인 특성을 운영자가 쉽게 구분할 수 있습니다. +- 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다. + +### Changed + +- 환경설정·발송 이력 화면의 "테스트 모드" 표기를 "검수 모드"로 통일했습니다. 실제 동작 변화는 없습니다. +- 알림 설정 화면에서 발송 설정이 미완료된 상태에서도 검수 모드 안내가 함께 표시되도록 했습니다. 두 안내 문구 사이 여백도 정리했습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php index fd029852..49a4ec70 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php @@ -32,6 +32,7 @@ return new class extends Migration $table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null') ->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete(); $table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)'); + $table->json('request_payload')->nullable()->comment('실제 비즈뿌리오 API 전송 요청 payload (개인식별 정보 제외 — to/refkey/type/본문 텍스트는 다른 컬럼과 중복이라 제외, account/from/senderkey/templatecode/버튼 등 요소만 저장)'); $table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null'); $table->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다'); $table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)'); @@ -39,6 +40,7 @@ return new class extends Migration $table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)'); $table->string('fallback_status', 20)->nullable()->comment('대체발송 결과: 성공/실패/없음 (webhook TELRES)'); $table->string('source', 10)->default('auto')->comment('발송 출처: auto / manual / bulk (DispatchSource enum, 1차 auto)'); + $table->boolean('is_test_mode')->nullable()->comment('발송 시점 검수 모드 여부 (null=컬럼 신설 이전 이력)'); $table->timestamp('sent_at')->nullable()->comment('발송 시각'); $table->timestamp('reported_at')->nullable()->comment('webhook 리포트 수신 시각 (replay 멱등 판정)'); $table->json('raw_payload')->nullable()->comment('webhook 원본 페이로드'); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php index 25e3ce49..e22d555f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php @@ -25,7 +25,7 @@ return new class extends Migration $table->bigIncrements('id')->comment('연결 설정 PK'); $table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)'); $table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)'); - $table->string('template_code', 30)->comment('연결한 카카오 알림톡 템플릿 코드'); + $table->string('template_code', 50)->comment('연결한 카카오 알림톡 템플릿 코드'); $table->string('template_name', 255)->comment('템플릿 이름 (사람 식별 + 고아 감지용 스냅샷)'); $table->boolean('fallback_sms_enabled')->default(false)->comment('개별 대체발송 ON/OFF (실패 시 SMS/LMS 대체)'); $table->boolean('is_active')->default(true)->comment('연동 활성 여부'); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md index 253fb224..903f5225 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -85,7 +85,7 @@ HTTP/1.1 200 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | notification_type | body | string | 예 | max 100 | | -| template_code | body | string | 아니오 | max 30 | | +| template_code | body | string | 아니오 | max 50 | | | template_name | body | string | 아니오 | max 255 | template 이름 (식별자) | | fallback_sms_enabled | body | boolean | 아니오 | — | | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 761931e6..ddd6f56b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -5,9 +5,12 @@ namespace Plugins\Sirsoft\MessageBizppurio; use App\Enums\ExtensionOwnerType; use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; +use App\Extension\Helpers\NotificationSyncHelper; use App\Extension\HookListenerRegistrar; use App\Extension\ModuleManager; +use App\Models\NotificationDefinition; use Database\Seeders\NotificationDefinitionSeeder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener; @@ -86,6 +89,14 @@ class Plugin extends AbstractPlugin * 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로 * 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리. * + * SeedChannelTemplatesListener 의 HookListenerRegistrar 등록 이력 캐시를 함께 지운다 — + * activate() 가 register() 로 등록한 뒤(process-wide idempotency 캐시, source::class 키), + * deactivate/uninstall 후 재활성화해도 이 캐시가 "이미 등록됨"으로 남아있으면 재등록이 + * 조용히 skip 되어 재시딩 시 sms·alimtalk 채널이 다시 붙지 않는다(회귀). 코어 + * HookListenerRegistrar 에는 특정 키 1건만 지우는 API 가 없고(clear() 는 전체 초기화라 + * 다른 확장의 등록 상태까지 날아감), 코어 수정 없이 이 플러그인 내부에서만 해결하기 + * 위해 리플렉션으로 private static $registered 캐시에서 이 키만 직접 제거한다. + * * @return bool 비활성화 성공 여부 */ public function deactivate(): bool @@ -96,21 +107,93 @@ class Plugin extends AbstractPlugin currentSlugs: [], ); + $this->forgetSeedChannelTemplatesListenerRegistration(); + return true; } /** - * 플러그인 제거 — 메뉴 잔재 안전망 (정상 흐름은 deactivate 가 먼저 처리). + * HookListenerRegistrar::$registered 캐시에서 SeedChannelTemplatesListener 등록 이력만 제거합니다. + * + * 코어 HookListenerRegistrar 는 개별 키 삭제 API 를 제공하지 않으므로(clear() 는 전체 + * 초기화), 코어를 수정하지 않고 이 캐시를 조작하기 위해 리플렉션을 사용한다. 실패해도 + * (리플렉션 예외 등) 치명적이지 않으므로 조용히 무시한다 — 최악의 경우 이번 재활성화만 + * 채널 재시딩이 안 붙고, 운영자가 다시 활성화하면 정상화된다. + */ + private function forgetSeedChannelTemplatesListenerRegistration(): void + { + try { + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $prop = $ref->getProperty('registered'); + $registered = $prop->getValue(); + $key = $this->getIdentifier().'::'.SeedChannelTemplatesListener::class; + unset($registered[$key]); + $prop->setValue(null, $registered); + } catch (\Throwable $e) { + Log::warning('[sirsoft-message_bizppurio] SeedChannelTemplatesListener 등록 캐시 초기화 실패', [ + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 플러그인 제거 — 메뉴 잔재 안전망(정상 흐름은 deactivate 가 먼저 처리) + sms·alimtalk 채널 잔재 정리. + * + * SeedChannelTemplatesListener 가 코어/게시판/이커머스 소유 알림 정의에 필터 훅으로 끼워넣은 + * sms·alimtalk template 은, 그 정의가 우리 소유가 아니므로 PluginManager 의 범용 알림 정의 + * 정리(cleanupStaleDefinitions('plugin', ...))에 걸리지 않는다. 코어는 이 채널의 존재나 + * 소유자를 몰라야 하므로, 코어를 수정하지 않고 우리가 직접 우리 채널 상수 기준으로 정리한다. + * + * "데이터도 함께 삭제" 옵션과 무관하게 항상 정리한다 — sms·alimtalk 은 이 플러그인 없이는 + * 발송이 불가능한 죽은 설정이라 보존할 가치가 없고, 재설치 시 activate() 가 재시딩하며 + * 자동으로 복원된다. * * @return bool 제거 성공 여부 */ public function uninstall(): bool { $this->deactivate(); + $this->cleanupChannelContributions(); return true; } + /** + * sms·alimtalk 채널 template 및 definitions.channels 배열에서 우리 채널 잔재를 정리합니다. + * + * 삭제 대상 채널은 우리가 이미 아는 채널 상수(RegisterNotificationChannelsListener::CHANNEL_IDS) + * 이므로, 각 정의가 원래 어떤 채널로 구성돼 있었는지 사전 지식 없이 "현재 DB 채널 목록 − + * 우리 채널" 만으로 남길 목록을 계산할 수 있다. template 삭제는 코어 공개 메서드 + * NotificationSyncHelper::cleanupStaleTemplates() 를 그대로 사용해 로깅·모델 이벤트 등 + * 코어 도메인 규칙을 그대로 따른다. + */ + private function cleanupChannelContributions(): void + { + $myChannels = RegisterNotificationChannelsListener::CHANNEL_IDS; + $helper = app(NotificationSyncHelper::class); + + $definitionIds = DB::table('notification_templates') + ->whereIn('channel', $myChannels) + ->distinct() + ->pluck('definition_id'); + + foreach ($definitionIds as $definitionId) { + $current = DB::table('notification_templates') + ->where('definition_id', $definitionId) + ->pluck('channel') + ->all(); + $keep = array_values(array_diff($current, $myChannels)); + + $helper->cleanupStaleTemplates($definitionId, $keep); + + $definition = NotificationDefinition::find($definitionId); + if ($definition) { + $definition->channels = $keep; + $definition->save(); + } + } + } + /** * 플러그인 권한 목록 반환 (계층 구조) * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json index 531ad00e..4e294559 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json @@ -72,9 +72,9 @@ "type": "basic", "name": "Span", "props": { - "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" }, - "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" }, { "type": "basic", @@ -138,9 +138,9 @@ "type": "basic", "name": "Span", "props": { - "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" + "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}" }, - "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}" }, { "type": "basic", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json index 9aa02141..a8b4fc52 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -49,7 +49,7 @@ "name": "Div", "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", "props": { - "className": "mt-4 mb-3" + "className": "mt-4 mb-3 space-y-2" }, "children": [ { @@ -111,7 +111,7 @@ "id": "bizppurio_banner_test_mode", "type": "basic", "name": "Div", - "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready !== false && (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", "props": { "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx index d4d196ce..6616bbb8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx @@ -1,3 +1,6 @@ +// e2e:allow 검수 라벨 표시(is_test_mode)는 Chrome MCP로 실제 회원가입→SMS 발송→화면 렌더까지 +// 실측 검증 완료(2026-07-24). 정식 E2E는 비즈뿌리오 자격증명·채널 활성화·webhook 등 발송 인프라 +// 의존이 커서 이번 변경 범위를 벗어나며, 별도 계획(plan-e2e-tests)에서 다룰 예정. /** * 코어 알림 발송 이력 결과 컬럼 주입 렌더 테스트 (A-2) * @@ -187,4 +190,44 @@ describe('A-2 결과 컬럼 주입 — 렌더', () => { expect(screen.queryByText('-')).not.toBeInTheDocument(); utils.cleanup(); }); + + it('검수 모드 발송 건은 상태 라벨(발송중) 대신 검수 라벨을 렌더한다', async () => { + // is_test_mode=true 이면 status='sent'(발송중)이어도 검수 라벨로 대체 표시한다(PO 확정 — + // "발송중" 문구 자체가 검수 모드에서는 오해 소지라 배지 병기가 아니라 라벨 교체). + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([4]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 4: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: true } } } }, + }); + await utils.render(); + // $t('key') 는 이 렌더 환경에서 번역 실패 시 원문 키를 그대로 반환한다($t: 와 동일 폴백). + expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).toBeInTheDocument(); + expect(screen.queryByText('발송중')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('운영 모드 발송 건은 검수 라벨 없이 기존 상태 라벨을 그대로 렌더한다', async () => { + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([5]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 5: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: false } } } }, + }); + await utils.render(); + expect(screen.getByText('발송중')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('is_test_mode 필드가 없는 과거 이력은 검수 라벨 없이 기존 라벨을 렌더한다', async () => { + // 컬럼 신설 이전 이력(is_test_mode 미포함)도 undefined === true 가 false 이므로 안전하게 기존 라벨. + const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([6]) }); + utils.mockApi('dispatchResults', { + response: { data: { results: { 6: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } }, + }); + await utils.render(); + expect(screen.getByText('성공 (4100)')).toBeInTheDocument(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx index a3637a10..f68e83cf 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -1,3 +1,5 @@ +// e2e:allow 검수 모드 배너(readiness 무관 노출)·배너 간격은 Chrome MCP로 알림톡 탭 실제 화면 +// 확인·수정까지 완료(2026-07-24). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸. /** * 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2) * @@ -75,6 +77,12 @@ describe('binding UI — 상태 배너 + 안내 박스', () => { expect(cond).toContain('is_test_mode === true'); }); + it('빨강(설정 미완료)·노랑(검수 모드) 배너가 동시에 뜰 때 간격이 있다(회귀: 두 배너가 붙어 보이던 문제)', () => { + const banner = findById(bannerRoot, 'bizppurio_status_banner'); + const className = (banner as { props?: { className?: string } }).props?.className ?? ''; + expect(className).toMatch(/space-y-\d/); + }); + it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready')); expect(raw).toContain('banner.not_ready'); @@ -82,6 +90,16 @@ describe('binding UI — 상태 배너 + 안내 박스', () => { expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); }); + it('검수 모드 배너는 readiness 충족 여부와 무관하게 is_test_mode 만으로 노출된다', () => { + // readiness 실패(예: 알림톡 API 키 미설정) + 검수 모드가 동시에 참인 상황에서도 + // 검수 안내가 가려지면 안 된다(회귀: 과거 readiness?.ready !== false 조건이 배너를 숨겼음). + const banner = findById(bannerRoot, 'bizppurio_banner_test_mode'); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).not.toContain('readiness'); + expect(cond).toContain('is_test_mode === true'); + }); + it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => { const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide'); expect(guide).toBeTruthy(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index db034ab3..70751cd3 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -9,12 +9,12 @@ "save_success": "Settings saved.", "save_failed": "Failed to save settings.", "test_mode": { - "label": "Test Mode", - "hint": "In test mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.", + "label": "Inspection Mode", + "hint": "In inspection mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.", "account_notice": "We recommend using separate Bizppurio accounts for inspection and production." }, "live_mode_warning_title": "Sending in production", - "live_mode_warning_body": "Test mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep test mode on while inspecting.", + "live_mode_warning_body": "Inspection mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep inspection mode on during inspection.", "sections": { "api": { "title": "API Integration", @@ -105,10 +105,11 @@ "banner": { "not_ready": "Setup required for delivery is incomplete.", "setup_action": "Set up", - "test_mode": "Test mode — messages are not actually sent." + "test_mode": "Inspection mode — messages are not actually sent." }, "dispatch_result": { "column_header": "SMS/Alimtalk Result", + "inspection_label": "Inspection", "low_balance": "Low balance", "fallback": "SMS fallback {status}", "detail_title": "SMS/Alimtalk delivery result", @@ -128,7 +129,7 @@ "missing_label": "Missing", "api_key_missing": "Kakao management API key", "sender_key_missing": "Alimtalk sender profile key", - "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao." + "note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off inspection mode and using templates approved by Kakao." }, "list_error": { "title": "Failed to load the template list" diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index a46b0e9c..610edbc9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -105,10 +105,11 @@ "banner": { "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", "setup_action": "설정하기", - "test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다." + "test_mode": "검수 모드입니다 — 실제 발송은 이뤄지지 않습니다." }, "dispatch_result": { "column_header": "문자·알림톡 결과", + "inspection_label": "검수", "low_balance": "잔액 부족", "fallback": "SMS 대체발송 {status}", "detail_title": "문자·알림톡 발송 결과", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php index 3ca1a11c..c9cfd3a7 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php @@ -33,7 +33,7 @@ class StoreNotificationBindingRequest extends FormRequest { return [ 'notification_type' => ['required', 'string', 'max:100'], - 'template_code' => ['nullable', 'string', 'max:30'], + 'template_code' => ['nullable', 'string', 'max:50'], 'template_name' => ['nullable', 'string', 'max:255'], 'fallback_sms_enabled' => ['sometimes', 'boolean'], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 85c61807..95983d24 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -38,8 +38,8 @@ class RegisterNotificationChannelsListener implements HookListenerInterface /** lang 네임스페이스 접두사 (plugin.php 와 동일) */ private const LANG = 'sirsoft-message_bizppurio::messages'; - /** 이 플러그인이 등록하는 채널 ID 목록 */ - private const CHANNEL_IDS = ['sms', 'alimtalk']; + /** 이 플러그인이 등록하는 채널 ID 목록 (Plugin::cleanupChannelContributions 에서도 참조) */ + public const CHANNEL_IDS = ['sms', 'alimtalk']; /** 3영역 채널 후보 확장을 구독할 hookPrefix (D7) */ private const CHANNEL_HOOK_PREFIXES = ['core.auth', 'sirsoft-ecommerce', 'sirsoft-board']; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php index fbb57f22..8d6b251f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php @@ -28,6 +28,7 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; * @property string|null $to_name * @property int|null $to_user_id * @property string $content + * @property array|null $request_payload * @property string|null $notification_type * @property int|null $notification_log_id * @property string $status @@ -35,6 +36,7 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; * @property string|null $result_message * @property string|null $fallback_status * @property string $source + * @property bool|null $is_test_mode * @property Carbon|null $sent_at * @property Carbon|null $reported_at * @property array|null $raw_payload @@ -65,6 +67,7 @@ class BizppurioDispatch extends Model 'to_name', 'to_user_id', 'content', + 'request_payload', 'notification_type', 'notification_log_id', 'status', @@ -72,6 +75,7 @@ class BizppurioDispatch extends Model 'result_message', 'fallback_status', 'source', + 'is_test_mode', 'sent_at', 'reported_at', 'raw_payload', @@ -90,8 +94,10 @@ class BizppurioDispatch extends Model 'source' => DispatchSource::class, 'to_user_id' => 'integer', 'notification_log_id' => 'integer', + 'is_test_mode' => 'boolean', 'sent_at' => 'datetime', 'reported_at' => 'datetime', + 'request_payload' => 'array', 'raw_payload' => 'array', ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php index e52b5025..30ce00e2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -49,6 +50,9 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificatio */ class AlimtalkChannelDriver { + /** 플러그인 식별자 (manifest 와 일치) */ + private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + /** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (SmsChannelDriver 와 동일 계약) */ public const RECIPIENT_PHONE_KEY = '_recipient_phone'; @@ -60,6 +64,7 @@ class AlimtalkChannelDriver * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) * @param KakaoTemplateContentResolver $kakaoContent 카카오 승인 템플릿 내용 조회·캐시(B안) * @param AlimtalkPayloadMapper $payloadMapper 카카오 내용 → 발송 형식 변환·치환(B안) + * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용) */ public function __construct( private readonly NotificationTemplateService $templateService, @@ -69,6 +74,7 @@ class AlimtalkChannelDriver private readonly DispatchLinkContext $linkContext, private readonly KakaoTemplateContentResolver $kakaoContent, private readonly AlimtalkPayloadMapper $payloadMapper, + private readonly PluginSettingsService $pluginSettings, ) {} /** @@ -148,9 +154,11 @@ class AlimtalkChannelDriver 'to_name' => $notifiable->name ?? null, 'to_user_id' => $this->resolveUserId($notifiable), 'content' => $message, + 'request_payload' => $this->payloadBuilder->forHistory($payload), 'notification_type' => $type, 'status' => DispatchStatus::Pending->value, 'source' => DispatchSource::Auto->value, + 'is_test_mode' => $this->isTestMode(), 'sent_at' => now(), ]); @@ -254,4 +262,16 @@ class AlimtalkChannelDriver { return Str::random(32); } + + /** + * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용). + * + * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책). + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php index 8efb4f6e..754f3596 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php @@ -120,7 +120,7 @@ class AlimtalkPayloadMapper // 링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 필드 + URL 변수 치환. foreach (self::LINK_FIELD_MAP as $from => $to) { if (isset($button[$from]) && $button[$from] !== '') { - $row[$to] = $this->substitute((string) $button[$from], $data); + $row[$to] = $this->substituteLinkField((string) $button[$from], $data); } } @@ -158,7 +158,7 @@ class AlimtalkPayloadMapper foreach (self::LINK_FIELD_MAP as $from => $to) { if (isset($link[$from]) && $link[$from] !== '') { - $mapped[$to] = $this->substitute((string) $link[$from], $data); + $mapped[$to] = $this->substituteLinkField((string) $link[$from], $data); } } @@ -258,4 +258,40 @@ class AlimtalkPayloadMapper return strtr($text, $replacements); } + + /** + * 버튼/링크 URL 필드를 치환하고, 프로토콜 중복을 방어합니다. + * + * 코어 알림 data 의 `*_url` 계열 변수(action_url 등)는 항상 `config('app.url')` 기반의 + * 프로토콜 포함 완전한 URL 이다(mail 채널이 href="{action_url}" 로 원문 그대로 소비하는 + * 계약). 반면 카카오 알림톡 버튼은 `http://#{action_url}` 처럼 원문에 프로토콜 접두어를 + * 직접 붙여 등록되는 경우가 있어, 단순 문자열 치환 시 `http://https://…` 형태로 프로토콜이 + * 중복될 수 있다. + * + * 원문이 `http://`/`https://` 로 시작 + 치환 결과가 (그 접두어를 제거했을 때) 다시 + * `http://`/`https://` 로 시작하는 경우에만 원문의 선행 접두어를 제거한다. 변수가 이미 + * 프로토콜 없이 등록된 경우(가이드 문서 원안)나, 변수값 자체가 프로토콜을 포함하지 않는 + * 경우(상대경로 등)는 원문을 그대로 둔다 — 후자를 건드리면 오히려 필요한 접두어가 사라진다. + * + * @param string $text 치환 대상(#{key} 포함, 링크 필드 원문) + * @param array $data 변수 치환 소스 + * @return string 치환되고 프로토콜 중복이 제거된 문자열 + */ + private function substituteLinkField(string $text, array $data): string + { + $substituted = $this->substitute($text, $data); + + foreach (['http://', 'https://'] as $prefix) { + if (! str_starts_with($text, $prefix)) { + continue; + } + + $afterPrefix = substr($substituted, strlen($prefix)); + if (str_starts_with($afterPrefix, 'http://') || str_starts_with($afterPrefix, 'https://')) { + return $afterPrefix; + } + } + + return $substituted; + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php index d21b49cd..37fe546c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php @@ -76,7 +76,7 @@ class DispatchResultService * dispatch 1건을 화면 표시용 결과 배열로 변환합니다. * * @param BizppurioDispatch $dispatch 연결된 발송 이력 - * @return array 상태·결과 라벨·잔액부족·대체발송 표시 데이터 + * @return array 상태·결과 라벨·잔액부족·대체발송·검수 모드 표시 데이터 */ private function buildResult(BizppurioDispatch $dispatch): array { @@ -92,6 +92,8 @@ class DispatchResultService 'is_low_balance' => $hasCode && $this->resultCodes->isBalanceLow($code), 'fallback_status' => $dispatch->fallback_status, 'channel' => $dispatch->channel?->value, + // 발송 시점 검수 모드 스냅샷. null=컬럼 신설 이전 이력(검수 여부 판단 불가 → 화면 미표시). + 'is_test_mode' => $dispatch->is_test_mode, ]; } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php index 2de0bace..672d0047 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php @@ -120,6 +120,34 @@ class MessagePayloadBuilder ]; } + /** + * 발송 payload 에서 발송 이력(request_payload) 저장용으로 개인식별 정보를 제거합니다. + * + * 이력 테이블에 이미 저장되는 필드(to→to_number, refkey→refkey, type→channel, + * content.{sms,lms,at}.message→content)는 완전 중복이므로 제외한다. 나머지(account/ + * from/senderkey/templatecode/button 등 요소·resend/recontent)는 이력 테이블 어디에도 + * 없고 발송 실패 원인 분석(결함①)·버튼 URL 검증(결함②)에 필요하므로 그대로 남긴다. + * + * @param array $payload buildSms/buildLms/buildAlimtalk 가 조립한 발송 payload + * @return array 개인식별 정보를 제거한 이력 저장용 payload + */ + public function forHistory(array $payload): array + { + unset($payload['to'], $payload['refkey'], $payload['type']); + + foreach (['sms', 'lms', 'at'] as $channelKey) { + if (isset($payload['content'][$channelKey]['message'])) { + unset($payload['content'][$channelKey]['message']); + } + } + + if (isset($payload['recontent']['sms']['message'])) { + unset($payload['recontent']['sms']['message']); + } + + return $payload; + } + /** * 발송 계정(account = bizppurio_id)을 반환합니다. */ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index f9a953dc..a7b92c9d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -8,6 +8,7 @@ use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -37,6 +38,9 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRep */ class SmsChannelDriver { + /** 플러그인 식별자 (manifest 와 일치) */ + private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio'; + /** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (extract_data 리스너와 계약) */ public const RECIPIENT_PHONE_KEY = '_recipient_phone'; @@ -46,6 +50,7 @@ class SmsChannelDriver * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2) + * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용) */ public function __construct( private readonly NotificationTemplateService $templateService, @@ -53,6 +58,7 @@ class SmsChannelDriver private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, private readonly DispatchLinkContext $linkContext, + private readonly PluginSettingsService $pluginSettings, ) {} /** @@ -114,9 +120,11 @@ class SmsChannelDriver 'to_name' => $notifiable->name ?? null, 'to_user_id' => $this->resolveUserId($notifiable), 'content' => $message, + 'request_payload' => $this->payloadBuilder->forHistory($payload), 'notification_type' => $type, 'status' => DispatchStatus::Pending->value, 'source' => DispatchSource::Auto->value, + 'is_test_mode' => $this->isTestMode(), 'sent_at' => now(), ]); @@ -174,4 +182,16 @@ class SmsChannelDriver { return Str::random(32); } + + /** + * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용). + * + * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책). + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php new file mode 100644 index 00000000..f19bfff6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php @@ -0,0 +1,144 @@ +plugin = new Plugin; + } + + /** + * activate() 로 채널이 증강된 상태에서 uninstall() 을 호출하면, 코어 알림 정의는 + * 보존한 채 sms·alimtalk template 과 channels 배열의 우리 채널만 제거된다. + */ + public function test_uninstall_시_sms_alimtalk_템플릿이_정리되고_코어_정의는_보존된다(): void + { + $this->plugin->activate(); + + $this->assertGreaterThan( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + '사전조건: activate() 후 sms·alimtalk 템플릿이 존재해야 한다.' + ); + + $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail(); + $this->assertContains('sms', $welcome->channels); + $this->assertContains('alimtalk', $welcome->channels); + + $this->plugin->uninstall(); + + $this->assertSame( + 0, + NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(), + 'uninstall() 후에는 sms·alimtalk 채널 템플릿이 하나도 남으면 안 된다.' + ); + + $welcome->refresh(); + $this->assertEqualsCanonicalizing( + ['mail', 'database'], + $welcome->channels, + 'welcome 정의는 삭제되지 않고, channels 배열에서 sms·alimtalk 만 제거되어야 한다.' + ); + $this->assertNotNull( + NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'mail')->first(), + 'mail 채널 템플릿은 그대로 보존되어야 한다.' + ); + $this->assertNotNull( + NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'database')->first(), + 'database 채널 템플릿은 그대로 보존되어야 한다.' + ); + } + + /** + * 게시판·이커머스처럼 코어가 아닌 다른 모듈이 소유한 알림 정의도 동일하게, + * 정의 자체는 남고 우리 채널만 제거된다. + */ + public function test_uninstall_시_모듈_소유_알림도_채널만_정리되고_정의는_보존된다(): void + { + $this->plugin->activate(); + + $boardDefinition = NotificationDefinition::where('extension_identifier', 'sirsoft-board')->first(); + + if (! $boardDefinition) { + $this->markTestSkipped('sirsoft-board 모듈이 활성화되지 않은 테스트 환경입니다.'); + } + + $this->assertContains('sms', $boardDefinition->channels); + + $this->plugin->uninstall(); + + $boardDefinition->refresh(); + $this->assertNotContains('sms', $boardDefinition->channels); + $this->assertNotContains('alimtalk', $boardDefinition->channels); + $this->assertSame( + 0, + NotificationTemplate::where('definition_id', $boardDefinition->id) + ->whereIn('channel', ['sms', 'alimtalk']) + ->count() + ); + } + + /** + * 제거 후 재설치(재활성화)하면 activate() 의 재시딩 경로로 채널이 다시 생성된다(회귀 방지). + */ + public function test_제거_후_재활성화하면_채널이_다시_생성된다(): void + { + $this->plugin->activate(); + $this->plugin->uninstall(); + + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + + $this->plugin->activate(); + + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'sms')->count(), + '재활성화 후 sms 채널 템플릿이 다시 생성되어야 한다.' + ); + $this->assertGreaterThan( + 0, + NotificationTemplate::where('channel', 'alimtalk')->count(), + '재활성화 후 alimtalk 채널 템플릿이 다시 생성되어야 한다.' + ); + + $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail(); + $this->assertContains('sms', $welcome->channels); + $this->assertContains('alimtalk', $welcome->channels); + } + + /** + * 채널 증강이 전혀 없는 상태(activate 를 호출하지 않은 상태)에서 uninstall() 을 + * 호출해도 예외 없이 안전하게 통과해야 한다(정리 대상 0건). + */ + public function test_증강된_채널이_없는_상태에서_uninstall해도_안전하다(): void + { + app(NotificationDefinitionSeeder::class)->run(); + + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + + $result = $this->plugin->uninstall(); + + $this->assertTrue($result); + $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php index 2406c567..da7b1562 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -2,6 +2,7 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests; +use App\Extension\HookListenerRegistrar; use App\Extension\HookManager; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; @@ -25,6 +26,21 @@ abstract class PluginTestCase extends TestCase */ private ?array $hookSnapshot = null; + /** + * HookListenerRegistrar::$registered 스냅샷 — tearDown 에서 복원. + * + * HookManager 의 hooks/filters 는 스냅샷 복원되지만, HookListenerRegistrar 는 + * "source::listenerClass" 키로 별도의 static 등록 여부 캐시를 갖는다(동일 PHP + * process 내 중복 등록 방지용, idempotent 보장). 이 캐시를 복원하지 않으면, + * 어떤 테스트에서 activate() 로 리스너를 한 번 등록한 뒤 HookManager 상태만 + * 복원되고 이 캐시는 "등록됨"으로 남아, 다음 테스트에서 같은 리스너를 다시 + * register() 해도 이미 등록된 것으로 판단해 스킵된다 — 실제로는 훅이 비어있는데 + * 등록 로직만 건너뛰어 필터가 전혀 발동하지 않는 조용한 실패로 이어진다. + * + * @var array|null + */ + private ?array $hookListenerRegistrarSnapshot = null; + /** 테스트 격리용 임시 plugins 스토리지 루트 (setUp 에서 생성, tearDown 에서 제거). */ private ?string $isolatedPluginsRoot = null; @@ -37,6 +53,7 @@ abstract class PluginTestCase extends TestCase $this->isolatePluginStorage(); $this->snapshotHookManager(); + $this->snapshotHookListenerRegistrar(); } /** @@ -45,6 +62,7 @@ abstract class PluginTestCase extends TestCase protected function tearDown(): void { $this->restoreHookManager(); + $this->restoreHookListenerRegistrar(); $this->restorePluginStorage(); parent::tearDown(); @@ -121,6 +139,31 @@ abstract class PluginTestCase extends TestCase $this->hookSnapshot = null; } + /** + * HookListenerRegistrar static $registered 를 스냅샷. + */ + private function snapshotHookListenerRegistrar(): void + { + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $prop = $ref->getProperty('registered'); + $this->hookListenerRegistrarSnapshot = $prop->getValue(); + } + + /** + * 스냅샷 시점으로 HookListenerRegistrar::$registered 를 복원. + */ + private function restoreHookListenerRegistrar(): void + { + if ($this->hookListenerRegistrarSnapshot === null) { + return; + } + + $ref = new \ReflectionClass(HookListenerRegistrar::class); + $ref->getProperty('registered')->setValue(null, $this->hookListenerRegistrarSnapshot); + + $this->hookListenerRegistrarSnapshot = null; + } + /** * 마이그레이션 경로를 반환합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php index 8fddd63d..c181392f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; @@ -102,12 +103,14 @@ class AlimtalkChannelDriverTest extends PluginTestCase * 템플릿·binding·빌더를 조합한 driver 를 만듭니다. * * @param array|null $kakao 카카오 상세조회 내용 (null=조회 실패) + * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용) */ private function makeDriver( ?NotificationTemplate $template, ?BizppurioNotificationBinding $binding, ?MessagePayloadBuilder $builder = null, array|false|null $kakao = false, + bool $isTestMode = true, ): AlimtalkChannelDriver { $templateService = Mockery::mock(NotificationTemplateService::class); $templateService->shouldReceive('resolve') @@ -117,6 +120,11 @@ class AlimtalkChannelDriverTest extends PluginTestCase // $kakao 기본값 false = "기본 카카오 내용 제공"(대부분 테스트). null = 조회 실패. $content = $kakao === false ? $this->kakaoContent() : $kakao; + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get') + ->with('sirsoft-message_bizppurio', 'is_test_mode', true) + ->andReturn($isTestMode); + return new AlimtalkChannelDriver( $templateService, $this->fakeBindings($binding), @@ -125,6 +133,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase new DispatchLinkContext, $this->fakeKakaoContent($content), new AlimtalkPayloadMapper, + $pluginSettings, ); } @@ -353,6 +362,44 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->assertSame('order_confirmed', $dispatch->notification_type); } + public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) + ->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); + $this->assertSame('TW_1234', $dispatch->request_payload['templatecode'] ?? null); + } + + public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외 + // 규칙(to/refkey/type/message 제거)을 검증할 수 있다. + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(''); + $realBuilder = new MessagePayloadBuilder($pluginSettings); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $realBuilder) + ->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $payload = $dispatch->request_payload; + + $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('message', $payload['content']['at'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayHasKey('templatecode', $payload['content']['at'] ?? [], 'templatecode 는 다른 컬럼에 없으므로 남아있어야 한다.'); + } + public function test_전화번호가_없으면_발송하지_않는다(): void { Bus::fake(); @@ -386,4 +433,26 @@ class AlimtalkChannelDriverTest extends PluginTestCase Bus::assertDispatched(SendMessageJob::class); $this->assertDatabaseCount('bizppurio_dispatches', 1); } + + public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: true) + ->send($member, $this->notification()); + + $this->assertTrue(BizppurioDispatch::first()->is_test_mode); + } + + public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: false) + ->send($member, $this->notification()); + + $this->assertFalse(BizppurioDispatch::first()->is_test_mode); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php index 4e8e4c03..750f9c43 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php @@ -148,6 +148,112 @@ class AlimtalkPayloadMapperTest extends PluginTestCase $this->assertSame('https://shop/X1', $result['extra']['link']['url_pc']); } + public function test_원문에_프로토콜_접두어가_있고_변수값도_완전url이면_중복을_제거한다(): void + { + // 결함② — 카카오 콘솔에 `http://#{action_url}` 로 등록된 버튼. action_url 자체가 + // config('app.url') 기반 완전 URL(https://...)이라 단순 치환 시 프로토콜이 중복된다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame( + 'https://ehkim.gnuboard.net/login', + $result['extra']['button'][0]['url_mobile'], + '원문 접두어(http://)를 제거해 프로토콜 중복(http://https://...)을 방지해야 한다.', + ); + } + + public function test_원문_접두어가_https이고_변수값도_https이면_중복을_제거한다(): void + { + // 실제 카카오 콘솔 등록값(관리자 화면 상세조회 스크린샷 확인, 2026-07-24) — + // "회원가입 환영" 템플릿 버튼이 http:// 가 아니라 https://#{action_url} 로 등록돼 있다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'https://#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame( + 'https://ehkim.gnuboard.net/login', + $result['extra']['button'][0]['url_mobile'], + '원문 접두어가 https:// 인 경우도 동일하게 중복(https://https://...)을 방지해야 한다.', + ); + } + + public function test_원문에_프로토콜_접두어가_없으면_변수값을_그대로_둔다(): void + { + // 가이드 문서 원안(#{action_url} 만 등록)대로면 애초에 중복이 없으므로 손대지 않는다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => '#{action_url}'], + ], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['button'][0]['url_mobile']); + } + + public function test_원문_접두어가_있어도_변수값이_상대경로면_접두어를_유지한다(): void + { + // 변수값 자체가 프로토콜을 포함하지 않는 경우(상대경로 등) 접두어는 진짜로 필요한 + // 부분이므로 제거하면 안 된다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '이동', 'linkType' => 'WL', 'linkMo' => 'http://#{path}'], + ], + ], + ['path' => '/foo'], + ); + + $this->assertSame('http:///foo', $result['extra']['button'][0]['url_mobile']); + } + + public function test_대표링크도_프로토콜_중복을_제거한다(): void + { + // mapLinkFields(대표링크)도 mapButtons 와 동일 규칙을 공유해야 한다. + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'templateRepresentLink' => ['linkMo' => 'http://#{action_url}'], + ], + ['action_url' => 'https://ehkim.gnuboard.net/login'], + ); + + $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['link']['url_mobile']); + } + + public function test_변수가_data에_없으면_원문을_유지한다(): void + { + // data 에 없는 변수는 원문(#{key}) 유지 정책 — 프로토콜 중복 방어 로직이 이 정책을 + // 깨서는 안 된다(원문이 http:// 로 시작하지만 치환 자체가 안 일어나므로 그대로 둔다). + $result = $this->mapper()->map( + [ + 'templateContent' => '본문', + 'buttons' => [ + ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{unknown_var}'], + ], + ], + [], + ); + + $this->assertSame('http://#{unknown_var}', $result['extra']['button'][0]['url_mobile']); + } + public function test_부재_필드는_extra에_넣지_않는다(): void { // 본문만 있는 단순 템플릿 → extra 는 비어야 한다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php index d9f3d269..5f1bbb5c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php @@ -90,6 +90,24 @@ class DispatchResultServiceTest extends PluginTestCase $this->assertNotNull($results[40]['status_label']); // 상태 라벨은 있음(발송중) } + public function test_검수_모드_스냅샷이_결과에_포함된다(): void + { + $this->linkedDispatch(70, ['is_test_mode' => true]); + + $results = $this->service->resultsForLogIds([70]); + + $this->assertTrue($results[70]['is_test_mode']); + } + + public function test_검수_모드_스냅샷이_없는_과거_이력은_null이다(): void + { + $this->linkedDispatch(71, ['is_test_mode' => null]); + + $results = $this->service->resultsForLogIds([71]); + + $this->assertNull($results[71]['is_test_mode']); + } + public function test_매칭되지_않는_로그id는_맵에서_빠진다(): void { $this->linkedDispatch(50); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php index 1963c693..af70caf9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php @@ -93,6 +93,77 @@ class MessagePayloadBuilderTest extends PluginTestCase $this->assertSame('확인', $at['button'][0]['name']); } + public function test_forHistory는_sms_payload에서_to_refkey_type_message를_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123'); + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('to', $history); + $this->assertArrayNotHasKey('refkey', $history); + $this->assertArrayNotHasKey('type', $history); + $this->assertArrayNotHasKey('message', $history['content']['sms']); + // 다른 컬럼에 없는 값은 유지돼야 한다. + $this->assertSame('acct01', $history['account']); + $this->assertSame('07012345678', $history['from']); + } + + public function test_forHistory는_lms_payload에서_subject는_유지하고_message만_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + ]); + + $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목'); + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('message', $history['content']['lms']); + $this->assertSame('제목', $history['content']['lms']['subject'], 'subject 는 다른 컬럼에 없으므로 유지돼야 한다.'); + } + + public function test_forHistory는_알림톡_payload에서_message만_제외하고_버튼_등_요소는_유지한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [ + 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']], + ]); + $history = $builder->forHistory($payload); + + $at = $history['content']['at']; + $this->assertArrayNotHasKey('message', $at, '치환된 본문은 content 컬럼과 중복이라 제외돼야 한다.'); + $this->assertSame('senderkey40', $at['senderkey'], 'senderkey 는 다른 컬럼에 없으므로 유지돼야 한다.'); + $this->assertSame('TW_1234', $at['templatecode'], 'templatecode 는 다른 컬럼에 없으므로 유지돼야 한다.'); + $this->assertSame('확인', $at['button'][0]['name'], '버튼(요소)은 결함② URL 검증에 필요하므로 유지돼야 한다.'); + } + + public function test_forHistory는_대체발송_recontent_message도_제외한다(): void + { + $builder = $this->makeBuilder([ + 'bizppurio_id' => 'acct01', + 'sender_number' => '07012345678', + 'sender_key' => 'senderkey40', + ]); + + $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999'); + $payload['resend'] = ['first' => 'sms']; + $payload['recontent'] = ['sms' => ['message' => '대체 SMS 본문']]; + + $history = $builder->forHistory($payload); + + $this->assertArrayNotHasKey('message', $history['recontent']['sms'], '대체발송 본문도 개인식별 텍스트이므로 제외돼야 한다.'); + $this->assertSame(['first' => 'sms'], $history['resend'], 'resend 구조 자체는 다른 컬럼에 없으므로 유지돼야 한다.'); + } + protected function tearDown(): void { Mockery::close(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index 407ba99e..c68a3393 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -7,6 +7,7 @@ use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; use App\Services\NotificationTemplateService; +use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; @@ -47,20 +48,30 @@ class SmsChannelDriverTest extends PluginTestCase * 템플릿 resolve 결과를 지정한 driver 를 만듭니다. * * @param NotificationTemplate|null $template resolve 반환값 + * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용) */ - private function makeDriver(?NotificationTemplate $template, ?MessagePayloadBuilder $builder = null): SmsChannelDriver - { + private function makeDriver( + ?NotificationTemplate $template, + ?MessagePayloadBuilder $builder = null, + bool $isTestMode = true, + ): SmsChannelDriver { $templateService = Mockery::mock(NotificationTemplateService::class); $templateService->shouldReceive('resolve') ->with(Mockery::any(), 'sms') ->andReturn($template); + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get') + ->with('sirsoft-message_bizppurio', 'is_test_mode', true) + ->andReturn($isTestMode); + return new SmsChannelDriver( $templateService, new SmsTypeResolver, $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, new DispatchLinkContext, + $pluginSettings, ); } @@ -160,6 +171,41 @@ class SmsChannelDriverTest extends PluginTestCase $this->assertNull($dispatch->to_user_id, '비회원 발송은 to_user_id 가 null 이어야 한다.'); } + public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.'); + $this->assertArrayNotHasKey('to', $dispatch->request_payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); + } + + public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외 + // 규칙(to/refkey/type/message 제거)을 검증할 수 있다. + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(''); + $realBuilder = new MessagePayloadBuilder($pluginSettings); + + $this->makeDriver($this->fakeTemplate(), $realBuilder)->send($member, $this->notification()); + + $dispatch = BizppurioDispatch::first(); + $payload = $dispatch->request_payload; + + $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.'); + $this->assertArrayNotHasKey('message', $payload['content']['sms'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.'); + } + public function test_발송하지_않으면_이력도_생성하지_않는다(): void { Bus::fake(); @@ -236,4 +282,24 @@ class SmsChannelDriverTest extends PluginTestCase Bus::assertDispatched(SendMessageJob::class); $this->assertDatabaseCount('bizppurio_dispatches', 1); } + + public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), isTestMode: true)->send($member, $this->notification()); + + $this->assertTrue(BizppurioDispatch::first()->is_test_mode); + } + + public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $this->makeDriver($this->fakeTemplate(), isTestMode: false)->send($member, $this->notification()); + + $this->assertFalse(BizppurioDispatch::first()->is_test_mode); + } } From e41f9c14c3807cbfbdb5d359f4f73eec8b1fa937 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 24 Jul 2026 19:21:18 +0900 Subject: [PATCH 20/34] =?UTF-8?q?fix(message=5Fbizppurio):=20SMS=20?= =?UTF-8?q?=EB=8C=80=EC=B2=B4=EB=B0=9C=EC=86=A1=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EA=B0=80=20=EB=AF=B8=EC=9A=94=EC=B2=AD=20=EA=B1=B4=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EC=9E=98=EB=AA=BB=20=ED=91=9C=EC=8B=9C=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비즈뿌리오가 대체발송 미요청 건에도 webhook TELRES/KAORES 값을 채워 보내 빈 문자열 검사만으로는 요청 여부를 구분할 수 없었다. 발송 시점에 우리가 실제로 대체발송을 요청했는지(request_payload의 resend 키 존재)를 판단 기준으로 바꿔, 미요청 건은 webhook 값과 무관하게 결과를 표시하지 않는다. --- .../sirsoft-message_bizppurio/CHANGELOG.md | 2 +- .../src/Services/WebhookReportService.php | 16 ++++- .../Feature/Webhook/BizppurioWebhookTest.php | 72 ++++++++++++++++++- 3 files changed, 86 insertions(+), 4 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 17684dd5..611c7ec6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -17,7 +17,7 @@ - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) - 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). -- 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다. +- 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다. 대체발송을 실제로 요청한 건에서만 결과가 표시되어, 사용하지 않은 건에 잘못 표시되지 않습니다. 알림톡은 카카오 승인 템플릿의 실제 발송 내용을 별도로 함께 보여줘, 위에 표시되는 알림 기본 본문과 헷갈리지 않도록 합니다. - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. - 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿의 목록·상태·내용을 확인할 수 있습니다. 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 하며, 이 화면은 목록·상태·내용 조회와 알림 연결을 담당합니다. 콘솔에서 등록한 템플릿도 이 목록에 그대로 표시되고 알림에 연결할 수 있습니다. - 템플릿 목록을 상태(발송가능·검수중·반려·미검수·중지 등) 배지와 함께 보여주고, 상태·이름으로 필터·검색할 수 있습니다. 발송가능(승인) 상태의 템플릿만 알림에 연결해 발송할 수 있습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php index 98b26299..0a566668 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php @@ -10,6 +10,7 @@ use App\Services\PluginSettingsService; use Plugins\Sirsoft\MessageBizppurio\Concerns\PreventsReplayWebhook; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Enums\ResultCategory; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; /** @@ -79,7 +80,7 @@ class WebhookReportService 'media' => $report['MEDIA'] ?? null, 'result_code' => $resultCode, 'result_message' => $this->resolver->reason($resultCode), - 'fallback_status' => $this->resolveFallbackStatus($report), + 'fallback_status' => $this->resolveFallbackStatus($report, $dispatch), 'reported_at' => now(), 'raw_payload' => $report['raw'] ?? $report, ]); @@ -131,11 +132,22 @@ class WebhookReportService /** * 대체발송 결과를 해석합니다 (webhook TELRES/KAORES). * + * 비즈뿌리오는 대체발송 미요청 건에도 TELRES/KAORES 에 "0" 같은 값을 채워 보내므로 + * (실측), webhook 응답값만으로는 대체발송 발생 여부를 판단할 수 없다. 우리가 발송 + * 시점에 실제로 대체발송을 요청했는지(`request_payload` 의 resend/recontent 존재)를 + * 먼저 확인해, 요청하지 않았으면 webhook 값과 무관하게 null 을 반환한다. + * * @param array $report 리포트 + * @param BizppurioDispatch $dispatch webhook 매칭된 발송 이력 (발송 시점 요청 payload 보유) * @return string|null 대체발송 결과 코드 또는 null */ - private function resolveFallbackStatus(array $report): ?string + private function resolveFallbackStatus(array $report, BizppurioDispatch $dispatch): ?string { + $requestPayload = $dispatch->request_payload ?? []; + if (! isset($requestPayload['resend'])) { + return null; + } + $telres = $report['TELRES'] ?? null; $kaores = $report['KAORES'] ?? null; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php index 00ad7efb..9d50d5d4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php @@ -40,9 +40,10 @@ class BizppurioWebhookTest extends PluginTestCase * * @param string $refkey 우리 부여 키 * @param string $channel 채널 + * @param array|null $requestPayload 발송 시점 요청 payload (resend 포함 여부 검증용) * @return BizppurioDispatch */ - private function seedDispatch(string $refkey, string $channel = 'sms'): BizppurioDispatch + private function seedDispatch(string $refkey, string $channel = 'sms', ?array $requestPayload = null): BizppurioDispatch { return BizppurioDispatch::create([ 'refkey' => $refkey, @@ -52,6 +53,7 @@ class BizppurioWebhookTest extends PluginTestCase 'notification_type' => 'welcome', 'status' => DispatchStatus::Sent->value, 'source' => 'auto', + 'request_payload' => $requestPayload, 'sent_at' => now(), ]); } @@ -246,4 +248,72 @@ class BizppurioWebhookTest extends PluginTestCase ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_noauth', 'RESULT' => '4100']) ->assertStatus(200); } + + /** + * 대체발송(SMS 대체발송)을 요청하지 않은 발송건(request_payload 에 resend 없음)은, + * webhook 응답에 TELRES/KAORES 값이 채워져 와도 fallback_status 가 null 로 남아야 한다. + * + * 비즈뿌리오는 대체발송 미요청 건에도 TELRES="0" 같은 값을 채워 보내므로(실서버 관측: + * alimtalk 대체발송 OFF 인데 결과 화면에 SMS 대체발송 뱃지가 표시된 회귀), webhook 값 + * 존재 여부만으로 판단하면 안 되고 우리가 실제로 요청했는지(request_payload)를 봐야 한다. + * + * @scenario source=webhook,ip=allowed,result=success,fallback_requested=false + * + * @effects fallback_status_remains_null_when_not_requested + */ + public function test_fallback_status_stays_null_when_resend_was_not_requested(): void + { + $dispatch = $this->seedDispatch( + 'ref_no_resend_requested', + DispatchChannel::Alimtalk->value, + ['account' => 'sirsoft', 'content' => ['at' => ['templatecode' => 'tpl_1']]], + ); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, [ + 'REFKEY' => 'ref_no_resend_requested', + 'RESULT' => '7000', + 'MEDIA' => 'KAT', + 'TELRES' => '0', + 'KAORES' => '0', + ]) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertNull($dispatch->fallback_status); + } + + /** + * 대체발송을 실제로 요청한 발송건(request_payload 에 resend 있음)은 webhook TELRES 값을 + * fallback_status 에 정상 반영한다. + * + * @scenario source=webhook,ip=allowed,result=balance_low,fallback_requested=true + * + * @effects fallback_status_set_when_resend_was_requested + */ + public function test_fallback_status_set_when_resend_was_requested(): void + { + $dispatch = $this->seedDispatch( + 'ref_resend_requested', + DispatchChannel::Alimtalk->value, + [ + 'account' => 'sirsoft', + 'content' => ['at' => ['templatecode' => 'tpl_1']], + 'resend' => ['first' => 'sms'], + ], + ); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, [ + 'REFKEY' => 'ref_resend_requested', + 'RESULT' => '7436', + 'MEDIA' => 'KAT', + 'TELRES' => '4100', + ]) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame('4100', $dispatch->fallback_status); + } } From efe1a81cb819d843b48475e477d4fc8669148ae5 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 24 Jul 2026 19:33:31 +0900 Subject: [PATCH 21/34] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=8B=A4=EC=A0=9C=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=EC=9D=84=20=EB=B0=9C=EC=86=A1=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B3=84=EB=8F=84=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코어 notification_logs.body는 알림톡의 경우 SMS 대체발송용 코어 템플릿 본문이라, 실제 카카오에 발송된 내용(승인 템플릿 전체)과 다르다. 코어 공용 파일은 다른 채널도 함께 쓰므로 직접 수정하지 않고, 결과 컬럼 오버레이 방식을 확장해 알림톡 채널일 때만 실제 발송 내용을 별도로 보여준다. --- .../docs/api/dispatch-results.md | 1 + .../extensions/notification_log_result.json | 35 ++++++ .../layouts/notification_log_result.test.tsx | 106 ++++++++++++++++++ .../resources/lang/en.json | 4 +- .../resources/lang/ko.json | 4 +- .../layouts/admin/plugin_settings.json | 3 +- .../src/Services/DispatchResultService.php | 4 + .../Services/DispatchResultServiceTest.php | 14 +++ 8 files changed, 167 insertions(+), 4 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md index 530e1c45..81b4f96b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md @@ -102,6 +102,7 @@ HTTP/1.1 200 | is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 | | fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null | | channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` | +| content | string\|null | 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 `notification_logs.body`(대체발송용 코어 템플릿 값)와 달리 카카오 승인 템플릿의 실제 내용이므로, 화면은 이 값을 채널별로 구분해 별도 노출한다 | **응답 예시** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json index 4e294559..fd8e707c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json @@ -95,6 +95,41 @@ "text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}" } ] + }, + { + "comment": "알림톡 실제 발송 내용 — 코어 '본문'(notification_logs.body)은 SMS 대체발송용 코어 템플릿 값이라 실제 카카오 발송 내용(승인 템플릿 전체)과 다르다. row.channel==='alimtalk'이고 실제 발송 내용이 있을 때만 별도 노출해 혼동을 방지한다.", + "type": "basic", + "name": "Div", + "if": "{{row.channel === 'alimtalk' && !!(dispatchResults?.data?.results ?? {})[row.id]?.content}}", + "props": { + "className": "pt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_label" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-line admin-card" + }, + "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.content}}" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-400 dark:text-gray-500 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_hint" + } + ] } ] } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx index 6616bbb8..fb0df8cb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx @@ -36,6 +36,14 @@ function getResultCellChildren(): any[] { return column.cellChildren; } +/** overlay 에서 행 토글(expandChildren) 에 append 된 결과 블록을 추출한다. */ +function getResultExpandChildren(): any[] { + const inj = (overlay as any).injections.find( + (i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props', + ); + return inj.props.expandChildren._append; +} + /** 결과 컬럼 cellChildren 을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */ function buildProbe() { return { @@ -57,6 +65,27 @@ function buildProbe() { }; } +/** 행 토글(expandChildren) 결과 블록을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */ +function buildExpandProbe() { + return { + version: '1.0.0', + layout_name: 'test/a2-dispatch-result-expand', + data_sources: [ + { id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true }, + { id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true }, + ], + components: [ + { + type: 'basic', + name: 'Div', + iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' }, + props: { 'data-testid': 'result-expand' }, + children: getResultExpandChildren(), + }, + ], + }; +} + let registry: MockComponentRegistry; beforeEach(() => { registry = createMockComponentRegistryWithBasics(); @@ -231,3 +260,80 @@ describe('A-2 결과 컬럼 주입 — 렌더', () => { utils.cleanup(); }); }); + +describe('A-2 행 토글 — 알림톡 실제 발송 내용', () => { + it('알림톡 채널 + 실제 발송 내용이 있으면 코어 "본문"과 별도로 렌더한다', async () => { + const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([7], 'alimtalk') }); + utils.mockApi('dispatchResults', { + response: { + data: { + results: { + 7: { + status: 'success', + status_label: '성공', + result_label: '성공 (7000)', + is_low_balance: false, + fallback_status: null, + channel: 'alimtalk', + content: '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.', + }, + }, + }, + }, + }); + await utils.render(); + expect(screen.getByText(/회원가입을 환영합니다/)).toBeInTheDocument(); + utils.cleanup(); + }); + + it('sms 채널은 실제 발송 내용 값이 있어도 렌더하지 않는다(코어 본문과 동일하므로 중복 표시 불필요)', async () => { + const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([8], 'sms') }); + utils.mockApi('dispatchResults', { + response: { + data: { + results: { + 8: { + status: 'success', + status_label: '성공', + result_label: '성공 (4100)', + is_low_balance: false, + fallback_status: null, + channel: 'sms', + content: '문자 본문', + }, + }, + }, + }, + }); + await utils.render(); + expect(screen.queryByText('문자 본문')).not.toBeInTheDocument(); + utils.cleanup(); + }); + + it('알림톡 채널이지만 실제 발송 내용이 없으면(과거 이력 등) 렌더하지 않는다', async () => { + const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' }); + utils.mockApi('notificationLogs', { response: logs([9], 'alimtalk') }); + utils.mockApi('dispatchResults', { + response: { + data: { + results: { + 9: { + status: 'success', + status_label: '성공', + result_label: '성공 (7000)', + is_low_balance: false, + fallback_status: null, + channel: 'alimtalk', + content: null, + }, + }, + }, + }, + }); + await utils.render(); + expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.sent_content_label')).not.toBeInTheDocument(); + utils.cleanup(); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index 70751cd3..a28c9e3a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -113,7 +113,9 @@ "low_balance": "Low balance", "fallback": "SMS fallback {status}", "detail_title": "SMS/Alimtalk delivery result", - "channel_label": "Channel: {channel}" + "channel_label": "Channel: {channel}", + "sent_content_label": "Actual sent content", + "sent_content_hint": "Alimtalk messages are sent using the actual content of the approved Kakao template, which may differ from the \"Body\" above." }, "editor": { "data_source": { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 610edbc9..43e5b8f6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -113,7 +113,9 @@ "low_balance": "잔액 부족", "fallback": "SMS 대체발송 {status}", "detail_title": "문자·알림톡 발송 결과", - "channel_label": "발송 채널: {channel}" + "channel_label": "발송 채널: {channel}", + "sent_content_label": "실제 발송 내용", + "sent_content_hint": "알림톡은 카카오 승인 템플릿의 실제 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다." }, "editor": { "data_source": { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json index 377df1bc..744dfaf0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json @@ -1467,7 +1467,7 @@ "comment": "조회 전용 — [내용] 버튼 하나. 상세 조회 후 내용 모달 열기(관리 액션 없음).", "type": "basic", "name": "Button", - "props": { "type": "button", "className": "btn btn-secondary btn-xs flex-center gap-1" }, + "props": { "type": "button", "className": "btn btn-secondary btn-xs flex-center gap-1 whitespace-nowrap" }, "actions": [ { "type": "click", @@ -1482,7 +1482,6 @@ } ], "children": [ - { "type": "basic", "name": "Icon", "props": { "name": "eye", "size": "sm" } }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.actions.detail" } ] } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php index 37fe546c..441379b9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php @@ -94,6 +94,10 @@ class DispatchResultService 'channel' => $dispatch->channel?->value, // 발송 시점 검수 모드 스냅샷. null=컬럼 신설 이전 이력(검수 여부 판단 불가 → 화면 미표시). 'is_test_mode' => $dispatch->is_test_mode, + // 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 notification_logs.body(대체발송용 코어 + // 템플릿 본문)와 다른 값(카카오 승인 템플릿 실제 내용)이라, 코어 "본문" 표시만으로는 + // 실제 발송 내용을 알 수 없다 — 화면이 채널별로 구분해 별도 노출할 수 있도록 포함. + 'content' => $dispatch->content, ]; } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php index 5f1bbb5c..c6608454 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php @@ -128,4 +128,18 @@ class DispatchResultServiceTest extends PluginTestCase $this->assertSame('success', $results[60]['status']); $this->assertTrue($results[61]['is_low_balance']); } + + /** + * 실제 발송 본문(dispatch.content)이 결과에 포함된다 — 알림톡은 코어 + * notification_logs.body(대체발송용 코어 템플릿 값)와 실제 카카오 발송 내용이 달라, + * 화면이 채널별로 구분해 실제 발송 내용을 보여줄 수 있도록 결과에 담아야 한다. + */ + public function test_실제_발송_본문이_결과에_포함된다(): void + { + $this->linkedDispatch(80, ['content' => '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.']); + + $results = $this->service->resultsForLogIds([80]); + + $this->assertSame('[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.', $results[80]['content']); + } } From e8784854aa4ce56c1c6e30b791bf5520d5fdd926 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 24 Jul 2026 19:34:19 +0900 Subject: [PATCH 22/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EA=B1=B4=EB=84=88=EB=9C=80=EC=9D=B4=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4=20=EC=9D=B4=EB=A0=A5=EC=97=90=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=98=A4=ED=91=9C=EC=8B=9C=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SMS 템플릿 없음·알림톡 카카오 템플릿 미연결 등으로 발송 자체를 시도하지 못한 경우, 채널 드라이버가 조용히 return하면 코어가 예외 미발생을 성공으로 착각해 발송 이력에 "성공"으로 잘못 기록했다. 드라이버가 NotificationSendSkippedException을 던지도록 바꿔, 코어의 기존 실패 처리 경로(channel_send_failed 훅)를 그대로 태워 "실패"로 정확히 기록되게 한다. 코어 파일은 수정하지 않는다. 실패 사유 메시지의 알림 유형도 내부 코드값(welcome) 대신 사람이 읽는 이름(회원가입 환영)으로 표시하도록 함께 개선했다. --- .../sirsoft-message_bizppurio/CHANGELOG.md | 1 + .../lang/en/messages.php | 9 ++ .../lang/ko/messages.php | 9 ++ .../NotificationSendSkippedException.php | 18 +++ .../src/Services/AlimtalkChannelDriver.php | 72 ++++++++---- .../src/Services/SmsChannelDriver.php | 54 +++++++-- .../Services/AlimtalkChannelDriverTest.php | 105 +++++++++++++++--- .../Unit/Services/SmsChannelDriverTest.php | 85 +++++++++++--- 8 files changed, 290 insertions(+), 63 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 611c7ec6..1997c91c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -34,6 +34,7 @@ - 사용 전 준비 안내를 문자(SMS/LMS)와 카카오 알림톡 두 갈래로 나눠 넓은 화면에서 좌우 두 칸으로 보여주고, 각 항목에 채널 아이콘을 더해 한눈에 구분되도록 정리했습니다. - 검수 모드로 발송된 문자·알림톡은 발송 이력 화면에 "발송중" 대신 "검수" 라벨로 표시되어, 실제 장애와 검수 도메인 특성을 운영자가 쉽게 구분할 수 있습니다. - 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다. +- 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다. ### Changed diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index 66f77757..f901b492 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -76,6 +76,15 @@ return [ 'sender_key_missing' => 'Please set the alimtalk sender profile key first.', ], + // Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log) + 'send_skipped' => [ + 'alimtalk_binding_missing' => 'Skipped sending: no alimtalk template is bound. (notification type: :type)', + 'alimtalk_kakao_content_unavailable' => 'Skipped sending: failed to fetch the approved Kakao template content. (notification type: :type)', + 'sms_template_missing' => 'Skipped sending: no SMS template found. (notification type: :type)', + 'recipient_phone_missing' => 'Skipped sending: recipient phone number is missing. (notification type: :type)', + 'message_body_empty' => 'Skipped sending: message body is empty. (notification type: :type)', + ], + // Notification-to-alimtalk template binding (NotificationBindingController responses) 'binding' => [ 'saved' => 'Alimtalk binding saved.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index 4144a4e9..a1fc0b42 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -76,6 +76,15 @@ return [ 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', ], + // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨) + 'send_skipped' => [ + 'alimtalk_binding_missing' => '알림톡 템플릿이 연결되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)', + 'alimtalk_kakao_content_unavailable' => '카카오 승인 템플릿 내용을 조회하지 못해 발송을 건너뛰었습니다. (알림 유형: :type)', + 'sms_template_missing' => 'SMS 템플릿이 없어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)', + 'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)', + ], + // 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답) 'binding' => [ 'saved' => '알림톡 연동을 저장했습니다.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php new file mode 100644 index 00000000..b52dde93 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php @@ -0,0 +1,18 @@ +definitionService->resolve($type)?->getLocalizedName(); + + return $label !== null && $label !== '' ? $label : $type; + } catch (\Throwable $e) { + return $type; + } + } + /** * 알림을 카카오 알림톡으로 발송합니다. * @@ -95,11 +120,13 @@ class AlimtalkChannelDriver $type = $notification->getType(); // 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송) + // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 + // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. $binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value); if ($binding === null) { - Log::info('비즈뿌리오 알림톡 발송 skip — 연결된 템플릿 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_binding_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로, @@ -107,29 +134,26 @@ class AlimtalkChannelDriver // 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다. $kakaoContent = $this->kakaoContent->resolve($binding->template_code); if ($kakaoContent === null) { - Log::info('비즈뿌리오 알림톡 발송 skip — 카카오 템플릿 내용 조회 실패', [ - 'type' => $type, - 'template_code' => $binding->template_code, - ]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_kakao_content_unavailable', ['type' => $this->resolveTypeLabel($type)]) + ); } // 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) $to = $this->resolvePhone($notifiable, $notification->getData()); if ($to === null) { - Log::info('비즈뿌리오 알림톡 발송 skip — 전화번호 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip. $mapped = $this->payloadMapper->map($kakaoContent, $notification->getData()); $message = (string) ($mapped['message'] ?? ''); if (trim($message) === '') { - Log::info('비즈뿌리오 알림톡 발송 skip — 본문 비어 있음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)]) + ); } // 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index a7b92c9d..6981b34c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -7,14 +7,15 @@ namespace Plugins\Sirsoft\MessageBizppurio\Services; use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; +use App\Services\NotificationDefinitionService; use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; -use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource; use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; +use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; @@ -26,13 +27,19 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRep * (`send($notifiable, Notification $notification)`)을 구현한다. * * 처리 흐름(계획서 Phase 3 ②): - * 1. sms 채널 알림 템플릿 resolve → 본문 렌더(변수 치환). 템플릿 없으면 skip(Phase 6 에서 - * 3영역 기본 body 시드 예정 — A안 확정). + * 1. sms 채널 알림 템플릿 resolve → 본문 렌더(변수 치환). 템플릿 없으면 + * NotificationSendSkippedException(Phase 6 에서 3영역 기본 body 시드 예정 — A안 확정). * 2. 전화번호 해석: 회원=Notifiable->mobile, 비회원=알림 data 의 _recipient_phone * (게스트 전화번호는 각 도메인 extract_data 리스너가 data 에 주입 — D1). * 3. refkey(우리 부여 unique 키) 생성 → SmsTypeResolver 로 SMS/LMS 판별 → * MessagePayloadBuilder 로 payload 조립 → SendMessageJob 위임(발송·재시도는 Job 책임). * + * 1~2단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라 + * NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의 + * catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 + * "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로 + * 기록하는 문제 — 이슈 #28). + * * 발송 이력(bizppurio_dispatches) 영속화는 Phase 4(테이블 신설)에서 이 흐름에 연결한다. * Phase 3 은 refkey 생성 + payload 조립 + Job 위임까지 담당한다. */ @@ -46,6 +53,7 @@ class SmsChannelDriver /** * @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve + * @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용) * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별 * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) @@ -54,6 +62,7 @@ class SmsChannelDriver */ public function __construct( private readonly NotificationTemplateService $templateService, + private readonly NotificationDefinitionService $definitionService, private readonly SmsTypeResolver $typeResolver, private readonly MessagePayloadBuilder $payloadBuilder, private readonly BizppurioDispatchRepositoryInterface $dispatches, @@ -61,6 +70,25 @@ class SmsChannelDriver private readonly PluginSettingsService $pluginSettings, ) {} + /** + * 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지용). + * + * 정의 조회 실패·이름 미설정 시 코드값(type)을 그대로 반환한다(안전 폴백). + * + * @param string $type 알림 유형 코드값 (welcome 등) + * @return string 사람이 읽는 이름 또는 코드값 + */ + private function resolveTypeLabel(string $type): string + { + try { + $label = $this->definitionService->resolve($type)?->getLocalizedName(); + + return $label !== null && $label !== '' ? $label : $type; + } catch (\Throwable $e) { + return $type; + } + } + /** * 알림을 문자(SMS/LMS)로 발송합니다. * @@ -79,19 +107,21 @@ class SmsChannelDriver $type = $notification->getType(); // 1. sms 채널 본문 템플릿 resolve (없으면 발송 안 함 — Phase 6 에서 기본 body 시드) + // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를 + // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다. $template = $this->templateService->resolve($type, DispatchChannel::Sms->value); if ($template === null || ! $template->is_active) { - Log::info('비즈뿌리오 SMS 발송 skip — sms 템플릿 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.sms_template_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) $to = $this->resolvePhone($notifiable, $notification->getData()); if ($to === null) { - Log::info('비즈뿌리오 SMS 발송 skip — 전화번호 없음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)]) + ); } // 3. 본문 렌더 (변수 치환) @@ -99,9 +129,9 @@ class SmsChannelDriver $rendered = $template->replaceVariables($notification->getData(), $locale); $message = (string) ($rendered['body'] ?? ''); if (trim($message) === '') { - Log::info('비즈뿌리오 SMS 발송 skip — 본문 비어 있음', ['type' => $type]); - - return; + throw new NotificationSendSkippedException( + __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)]) + ); } // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php index c181392f..563b8297 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -6,11 +6,13 @@ use App\Models\NotificationTemplate; use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; +use App\Services\NotificationDefinitionService; use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding; @@ -125,8 +127,13 @@ class AlimtalkChannelDriverTest extends PluginTestCase ->with('sirsoft-message_bizppurio', 'is_test_mode', true) ->andReturn($isTestMode); + // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백. + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->andReturn(null); + return new AlimtalkChannelDriver( $templateService, + $definitionService, $this->fakeBindings($binding), $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, @@ -173,28 +180,94 @@ class AlimtalkChannelDriverTest extends PluginTestCase return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); } - public function test_연결된_템플릿이_없으면_발송하지_않는다(): void + public function test_연결된_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // binding = null → 미연결 → skip - $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification()); + // binding = null → 미연결 → NotificationSendSkippedException(코어가 실패로 기록하도록) + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); - $this->assertDatabaseCount('bizppurio_dispatches', 0); + try { + $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } } - public function test_카카오_템플릿_내용_조회_실패시_발송하지_않는다(): void + public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음 → skip - $this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null) - ->send($member, $this->notification()); + $definition = Mockery::mock(\App\Models\NotificationDefinition::class); + $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영'); - Bus::assertNotDispatched(SendMessageJob::class); + $definitionService = Mockery::mock(\App\Services\NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition); + + $templateService = Mockery::mock(\App\Services\NotificationTemplateService::class); + $templateService->shouldReceive('resolve')->with(Mockery::any(), 'alimtalk')->andReturn(null); + + $pluginSettings = Mockery::mock(\App\Services\PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(true); + + $driver = new AlimtalkChannelDriver( + $templateService, + $definitionService, + $this->fakeBindings(null), + $this->spyBuilder(), + new BizppurioDispatchRepository, + new DispatchLinkContext, + $this->fakeKakaoContent($this->kakaoContent()), + new AlimtalkPayloadMapper, + $pluginSettings, + ); + + try { + $driver->send($member, $this->notification()); + $this->fail('NotificationSendSkippedException 가 발생해야 한다.'); + } catch (NotificationSendSkippedException $e) { + $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); + $this->assertStringNotContainsString('order_confirmed', $e->getMessage()); + } + } + + public function test_카카오_템플릿_내용_조회_실패시_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음 + // → NotificationSendSkippedException(코어가 실패로 기록하도록) + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + public function test_카카오_치환_결과_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 카카오 templateContent 가 치환 후에도 빈 문자열이면 발송 불가 + $this->expectException(NotificationSendSkippedException::class); + + try { + $this->makeDriver( + $this->fakeTemplate(), + $this->binding(), + kakao: ['templateCode' => 'TW_1234', 'templateContent' => ' '], + )->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_코어_alimtalk_템플릿이_없어도_카카오_내용으로_발송한다(): void @@ -400,15 +473,19 @@ class AlimtalkChannelDriverTest extends PluginTestCase $this->assertArrayHasKey('templatecode', $payload['content']['at'] ?? [], 'templatecode 는 다른 컬럼에 없으므로 남아있어야 한다.'); } - public function test_전화번호가_없으면_발송하지_않는다(): void + public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); - $this->makeDriver($this->fakeTemplate(), $this->binding()) - ->send($guest, $this->notification(['name' => '홍길동'])); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver($this->fakeTemplate(), $this->binding()) + ->send($guest, $this->notification(['name' => '홍길동'])); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_generic_notification이_아니면_무시한다(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index c68a3393..ec1fe165 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -6,11 +6,13 @@ use App\Models\NotificationTemplate; use App\Models\User; use App\Notifications\GenericNotification; use App\Notifications\GuestNotifiable; +use App\Services\NotificationDefinitionService; use App\Services\NotificationTemplateService; use App\Services\PluginSettingsService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; @@ -65,8 +67,13 @@ class SmsChannelDriverTest extends PluginTestCase ->with('sirsoft-message_bizppurio', 'is_test_mode', true) ->andReturn($isTestMode); + // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백. + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->andReturn(null); + return new SmsChannelDriver( $templateService, + $definitionService, new SmsTypeResolver, $builder ?? $this->spyBuilder(), new BizppurioDispatchRepository, @@ -107,14 +114,54 @@ class SmsChannelDriverTest extends PluginTestCase return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); } - public function test_템플릿이_없으면_발송하지_않는다(): void + public function test_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - $this->makeDriver(null)->send($member, $this->notification()); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver(null)->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } + } + + public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + $definition = Mockery::mock(\App\Models\NotificationDefinition::class); + $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영'); + + $definitionService = Mockery::mock(NotificationDefinitionService::class); + $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition); + + $templateService = Mockery::mock(NotificationTemplateService::class); + $templateService->shouldReceive('resolve')->with(Mockery::any(), 'sms')->andReturn(null); + + $pluginSettings = Mockery::mock(PluginSettingsService::class); + $pluginSettings->shouldReceive('get')->andReturn(true); + + $driver = new SmsChannelDriver( + $templateService, + $definitionService, + new SmsTypeResolver, + $this->spyBuilder(), + new BizppurioDispatchRepository, + new DispatchLinkContext, + $pluginSettings, + ); + + try { + $driver->send($member, $this->notification()); + $this->fail('NotificationSendSkippedException 가 발생해야 한다.'); + } catch (NotificationSendSkippedException $e) { + $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.'); + $this->assertStringNotContainsString('order_confirmed', $e->getMessage()); + } } public function test_회원은_mobile로_발송한다(): void @@ -211,21 +258,29 @@ class SmsChannelDriverTest extends PluginTestCase Bus::fake(); $member = User::factory()->create(['mobile' => '010-1234-5678']); - // 템플릿 없음 → skip → 이력도 없어야 함 - $this->makeDriver(null)->send($member, $this->notification()); + // 템플릿 없음 → NotificationSendSkippedException → 이력도 없어야 함 + $this->expectException(NotificationSendSkippedException::class); - $this->assertDatabaseCount('bizppurio_dispatches', 0); + try { + $this->makeDriver(null)->send($member, $this->notification()); + } finally { + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } } - public function test_전화번호가_없으면_발송하지_않는다(): void + public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); // data 에 전화번호 없음 + 게스트라 mobile 속성 없음 - $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([])); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([])); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_짧은_본문은_sm_s로_긴_본문은_lm_s로_보낸다(): void @@ -248,15 +303,19 @@ class SmsChannelDriverTest extends PluginTestCase $this->assertSame('제목', $lmsBuilder->calls[0]['subject'], 'LMS 는 subject(코어 제목)를 재사용해야 한다.'); } - public function test_본문이_비어있으면_발송하지_않는다(): void + public function test_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void { Bus::fake(); $member = User::factory()->create(['mobile' => '01011112222']); - $this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' '])) - ->send($member, $this->notification()); + $this->expectException(NotificationSendSkippedException::class); - Bus::assertNotDispatched(SendMessageJob::class); + try { + $this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' '])) + ->send($member, $this->notification()); + } finally { + Bus::assertNotDispatched(SendMessageJob::class); + } } public function test_generic_notification이_아니면_무시한다(): void From 8521428636b0fba2248ddf7f8dcc380122177967 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 24 Jul 2026 20:08:03 +0900 Subject: [PATCH 23/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B2=B0=EA=B3=BC=20=EC=97=86=EC=9D=8C=EC=9D=B4=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A1=9C=20=EC=B2=98=EB=A6=AC=EB=90=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 카카오 kapi 결과코드 508("요청한 데이터가 없음")은 검색 결과 0건을 나타내는 정상 응답인데, 예외로 전파해 화면에 아무 반응 없이 이전 목록만 남는 문제가 있었다. 목록 조회에서만 508을 빈 결과로 흡수하도록 수정. --- .../sirsoft-message_bizppurio/CHANGELOG.md | 2 +- .../src/Services/AlimtalkTemplateService.php | 25 +++++++++++++++++-- .../AlimtalkTemplateControllerTest.php | 20 +++++++++++++++ .../Services/AlimtalkTemplateServiceTest.php | 15 +++++++++++ .../tests/scenarios/alimtalk-templates.yaml | 6 ++++- 5 files changed, 64 insertions(+), 4 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 1997c91c..020423cb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -20,7 +20,7 @@ - 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다. 대체발송을 실제로 요청한 건에서만 결과가 표시되어, 사용하지 않은 건에 잘못 표시되지 않습니다. 알림톡은 카카오 승인 템플릿의 실제 발송 내용을 별도로 함께 보여줘, 위에 표시되는 알림 기본 본문과 헷갈리지 않도록 합니다. - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. - 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿의 목록·상태·내용을 확인할 수 있습니다. 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 하며, 이 화면은 목록·상태·내용 조회와 알림 연결을 담당합니다. 콘솔에서 등록한 템플릿도 이 목록에 그대로 표시되고 알림에 연결할 수 있습니다. -- 템플릿 목록을 상태(발송가능·검수중·반려·미검수·중지 등) 배지와 함께 보여주고, 상태·이름으로 필터·검색할 수 있습니다. 발송가능(승인) 상태의 템플릿만 알림에 연결해 발송할 수 있습니다. +- 템플릿 목록을 상태(발송가능·검수중·반려·미검수·중지 등) 배지와 함께 보여주고, 상태·이름으로 필터·검색할 수 있습니다. 검색 결과가 없으면 오류 없이 "등록된 알림톡 템플릿이 없습니다"로 표시됩니다. 발송가능(승인) 상태의 템플릿만 알림에 연결해 발송할 수 있습니다. - 각 템플릿의 [내용] 버튼으로 템플릿명·코드·카테고리·유형·본문·버튼을 담은 상세 내용을 확인할 수 있습니다. - 목록 상단에 배지 의미(발송가능·검수중·미검수·반려)와 함께, 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 한다는 안내와 콘솔 바로가기를 제공합니다. - 환경설정 탭에 카카오 알림톡·문자(SMS/LMS) 사용 전 준비 사항(비즈뿌리오 가입·API 승인·발신프로필 등록·발신번호 사전 등록)을 안내하는 박스를 추가했습니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php index 2321a85f..1280b8b5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php @@ -29,6 +29,9 @@ class AlimtalkTemplateService /** 목록 조회 기본 페이지 크기 */ private const DEFAULT_COUNT = 20; + /** kapi 결과코드: 요청한 데이터가 없음(검색 결과 0건 포함, 13.응답코드정의.md) */ + private const NOT_FOUND_CODE = '508'; + /** * serviceStatus → 상태 배지 매핑. * @@ -63,7 +66,7 @@ class AlimtalkTemplateService * @param array $filters status(templateStatus)·keyword·page·count * @return array{templates: array>, pagination: array} * - * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시(결과코드 508 제외) */ public function list(array $filters = []): array { @@ -82,7 +85,25 @@ class AlimtalkTemplateService $response = $this->kakao->getTemplateList($this->senderKey(), $params); - $this->assertSuccess($response); + try { + $this->assertSuccess($response); + } catch (BizppurioApiException $e) { + // 508 = "요청한 데이터가 없음"(검색 결과 0건 포함). 카카오는 이 경우를 목록 + // 조회 실패로 응답하지만, 실제로는 정상적인 빈 결과이므로 예외로 취급하지 않는다. + if ($e->getResultCode() === self::NOT_FOUND_CODE) { + return [ + 'templates' => [], + 'pagination' => [ + 'total' => 0, + 'total_page' => 1, + 'current_page' => $params['page'], + 'per_page' => $params['count'], + ], + ]; + } + + throw $e; + } $rows = (array) ($response['data']['list'] ?? $response['data'] ?? []); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php index 54825362..7548335e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php @@ -191,6 +191,26 @@ class AlimtalkTemplateControllerTest extends PluginTestCase $response->assertJsonPath('errors.kakao_message', '발신프로필을 찾을 수 없습니다.'); } + /** + * @scenario auth=view,kapi_result=not_found + * + * @effects list_treats_kapi_508_as_empty_result_not_error + */ + public function test_kapi_508은_에러가_아니라_빈_목록으로_응답한다(): void + { + $this->mockService()->shouldReceive('list')->once()->andReturn([ + 'templates' => [], + 'pagination' => ['total' => 0, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20], + ]); + + $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view'])) + ->getJson(self::BASE.'?keyword=대글'); + + $response->assertStatus(200); + $response->assertJsonPath('data.templates', []); + $response->assertJsonPath('data.pagination.total', 0); + } + /** * @scenario auth=manage,action=clear_cache * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php index 036de8ca..4c2093de 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php @@ -150,6 +150,21 @@ class AlimtalkTemplateServiceTest extends PluginTestCase } } + public function test_목록조회는_kapi_508을_빈_목록으로_처리한다(): void + { + // 카카오 결과코드 508 = "요청한 데이터가 없음"(13.응답코드정의.md). 목록 검색에서 + // 매칭 결과가 0건일 때 카카오가 이 코드로 응답하므로, 진짜 에러가 아니라 빈 목록으로 + // 취급해야 한다(PO 실측: "댓글"은 200 정상 필터링, "대글"처럼 매칭 없는 키워드만 508). + Http::fake([ + 'kapi.ppurio.com/*' => Http::response(['code' => '508', 'message' => '요청한 데이타가 없습니다.'], 200), + ]); + + $result = $this->service()->list(['keyword' => '대글']); + + $this->assertSame([], $result['templates']); + $this->assertSame(0, $result['pagination']['total']); + } + protected function tearDown(): void { Mockery::close(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml index fda48040..48bb8ddb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml @@ -27,11 +27,13 @@ description: | axes: auth: [view, forbidden, guest] - kapi_result: [ok, fail] + kapi_result: [ok, fail, not_found] exclusions: - { auth: guest, kapi_result: fail, reason: "비인증은 결과 무관 401 — kapi 도달 전 차단" } - { auth: forbidden, kapi_result: fail, reason: "권한 없으면 403 — kapi 도달 전 차단" } + - { auth: guest, kapi_result: not_found, reason: "비인증은 결과 무관 401 — kapi 도달 전 차단" } + - { auth: forbidden, kapi_result: not_found, reason: "권한 없으면 403 — kapi 도달 전 차단" } effects: # 탭 네비게이션 (UI) @@ -77,6 +79,8 @@ effects: # kapi 실패 전파 (백엔드) - kapi_failure_is_surfaced_as_422_with_result_code - sender_key_missing_throws + # kapi 508(요청한 데이터가 없음=검색 결과 0건) 은 에러가 아니라 빈 목록으로 처리 (백엔드) + - list_treats_kapi_508_as_empty_result_not_error # 다국어 - all_referenced_i18n_keys_exist_in_ko_and_en From 9dee9f5e35ac1e0496c9028e1ba13dc2c5115e07 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 24 Jul 2026 20:15:53 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat(lang-pack):=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=BF=8C=EB=A6=AC=EC=98=A4=20=EB=A9=94=EC=8B=9C=EC=A7=95=20?= =?UTF-8?q?=ED=94=8C=EB=9F=AC=EA=B7=B8=EC=9D=B8=20=EC=9D=BC=EB=B3=B8?= =?UTF-8?q?=EC=96=B4=20=EB=B2=88=EB=93=A4=20=EC=96=B8=EC=96=B4=ED=8C=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sirsoft-message_bizppurio 플러그인의 ja 언어팩을 신규 생성한다. 빌더 PACKAGES 테이블에 패키지를 등록하고, backend(messages·result_codes· activity_log)·frontend·seed(notifications·permissions·manifest) 번역과 매니페스트·CHANGELOG를 함께 추가한다. --- .../CHANGELOG.md | 12 + .../backend/ja/activity_log.php | 6 + .../backend/ja/messages.php | 75 ++++++ .../backend/ja/result_codes.php | 49 ++++ .../frontend/ja.json | 218 ++++++++++++++++++ .../language-pack.json | 30 +++ .../seed/manifest.json | 4 + .../seed/notifications.json | 18 ++ .../seed/permissions.json | 18 ++ 9 files changed, 430 insertions(+) create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md new file mode 100644 index 00000000..d4ec2274 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +이 언어팩의 모든 주요 변경사항을 기록합니다. +형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, +[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. + +## [1.0.0] - 2026-07-24 + +### Added + +- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공 +- 환경설정·알림톡 템플릿 관리·발송 이력 등 관리자 화면 텍스트와 발송 결과 코드 안내의 일본어 번역 추가 — 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php new file mode 100644 index 00000000..61dc2267 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php @@ -0,0 +1,6 @@ + [], + 'description' => [], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php new file mode 100644 index 00000000..b021ed3c --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -0,0 +1,75 @@ + [ + 'sms' => 'SMS', + 'lms' => 'LMS', + 'alimtalk' => 'アラートトーク', + ], + 'status' => [ + 'pending' => '待機中', + 'sent' => '送信中', + 'success' => '成功', + 'failed' => '失敗', + ], + 'source' => [ + 'auto' => '自動', + 'manual' => '手動', + 'bulk' => '一括', + ], + 'result_category' => [ + 'success' => '成功', + 'retry' => '再試行', + 'permanent_failure' => '永久失敗', + 'balance_low' => '残高不足', + ], + 'channels' => [ + 'source_label' => 'ビズプリオ', + 'sms' => [ + 'name' => 'SMS/LMSテキスト', + 'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。', + ], + 'alimtalk' => [ + 'name' => 'カカオアラートトーク', + 'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。', + ], + ], + 'readiness' => [ + 'sms_credentials_missing' => 'ビズプリオのアイディとパスワードを設定してください。', + 'sms_sender_number_missing' => '発信番号を設定してください。', + 'alimtalk_api_key_missing' => 'カカオ管理API キーを設定してください。', + 'alimtalk_sender_key_missing' => 'アラートトーク発信プロフィールキーを設定してください。', + ], + 'settings' => [ + 'bizppurio_id_attribute' => 'ビズプリオアイディ', + 'password_attribute' => 'パスワード', + 'sender_number_attribute' => '発信番号', + ], + 'webhook' => [ + 'received' => 'レポートを受け取りました。', + ], + 'error' => [ + 'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。', + 'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。', + 'send_failed' => 'メッセージ送信リクエストに失敗しました。', + 'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)', + 'invalid_response' => 'ビズプリオレスポンスを解析できません。', + 'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。', + 'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。', + 'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。', + ], + 'send_skipped' => [ + 'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)', + 'alimtalk_kakao_content_unavailable' => 'カカオ承認テンプレート内容を照会できないため送信をスキップしました。(通知タイプ: :type)', + 'sms_template_missing' => 'SMSテンプレートがないため送信をスキップしました。(通知タイプ: :type)', + 'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)', + 'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)', + ], + 'binding' => [ + 'saved' => 'アラートトーク連携を保存しました。', + 'removed' => 'アラートトーク連携を解除しました。', + ], + 'cache' => [ + 'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。', + ], +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php new file mode 100644 index 00000000..b7682ea9 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php @@ -0,0 +1,49 @@ + '成功', + '2000' => 'リクエストパラメータエラー', + '3001' => '必須パラメータが不足しています', + '3002' => 'トークン無効(期限切れ·廃止)', + '3004' => '許可されていないリクエスト', + '3005' => '認証情報が無効です', + '3006' => 'アカウント情報エラー', + '3007' => '発信番号が未登録です', + '3009' => 'メッセージタイプエラー', + '3011' => '一時的な処理遅延', + '3013' => '一時的なシステムエラー', + '4100' => '成功', + '4400' => '電波の弱い地域', + '4401' => '電源がオフです', + '4402' => 'ストレージ容量超過', + '4410' => '無効な番号', + '4414' => '欠番·停止', + '4420' => 'その他の端末エラー', + '4430' => 'スパム受信拒否', + '4443' => 'その他のエラー', + '5003' => '一時的な送信エラー', + '5004' => '一時的な送信エラー', + '5005' => '一時的な送信エラー', + '6600' => '成功', + '6603' => '電波の弱い地域', + '6604' => '電源がオフです', + '6606' => '無効な番号', + '6621' => 'メッセージ長が超過しています', + '6641' => 'スパムブロック', + '7000' => '成功', + '7106' => '削除された発信キー', + '7107' => 'ブロックされた発信キー', + '7204' => 'テンプレート不一致', + '7206' => '審査されていないテンプレート', + '7306' => 'カカオシステムエラー', + '7307' => '処理遅延', + '7308' => '電話番号エラー', + '7320' => '受信ブロック', + '7325' => '変数長が超過しています', + '7421' => 'タイムアウト', + '7436' => 'ウォレット残高不足(アラームトーク)', + '7437' => 'メッセージ要求失敗', + '7523' => 'その他のエラー', + '9000' => '一時的なシステムエラー', + '9070' => '残高不足(メール)', +]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json new file mode 100644 index 00000000..0e68d130 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json @@ -0,0 +1,218 @@ +{ + "name": "Biznorio メッセージ発送", + "description": "Biznorio 連動 SMS/LMS・カカオ アラート톡 発送プラグインです。", + "settings": { + "title": "Biznorio メッセージ発送 設定", + "description": "Biznorio 連動情報と発送 設定を管理します。", + "save": "保存", + "saving": "保存 中...", + "save_success": "設定が保存されました。", + "save_failed": "設定 保存に失敗しました。", + "test_mode": { + "label": "検査モード", + "hint": "検査モードでは Biznorio 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。", + "account_notice": "検査と運用は別の Biznorio アカウントで運営することをお勧めします。" + }, + "live_mode_warning_title": "運用環境で発送されます", + "live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラート톡が発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。", + "sections": { + "api": { + "title": "API 連動", + "description": "発送システムとカカオ管理システムに共通に使用する連動情報です。" + }, + "sending": { + "title": "発送 設定", + "description": "文字・アラート톡発送に使用する発信情報です。" + } + }, + "fields": { + "bizppurio_id": { + "label": "Biznorio ID", + "hint": "発送・カカオ管理に共通に使用する Biznorio ID です。" + }, + "password": { + "label": "パスワード", + "hint": "発送トークン発行に使用する Biznorio パスワードです。" + }, + "api_key": { + "label": "API キー", + "hint": "API キーは Biznorio カスタマーセンターに ID とともに申し込むと確認後、発行されます。" + }, + "sender_number": { + "label": "発信番号", + "hint": "文字・アラート톡発送に使用する発信電話番号です。" + }, + "sender_key": { + "label": "アラート톡 発信プロファイル キー", + "hint": "アラート톡 発送・テンプレート確認に使用する発信プロファイル キー(40文字)です。" + }, + "template_cache_minutes": { + "label": "アラート톡 内容 キャッシュ 時間(分)", + "hint": "カカオ テンプレート内容をこの時間だけ再利用します。0 = 毎回最新(発送が多い場合は非推奨)。", + "clear_cache": "キャッシュ 初期化", + "clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。", + "clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。", + "clear_cache_failed": "キャッシュ 初期化に失敗しました。" + } + }, + "report": { + "section_title": "レポート受信 設定", + "hint": "Biznorio は文字・アラート톡発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを Biznorio ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。", + "note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。", + "copy": "コピー", + "copied": "レポート受信アドレスがクリップボードにコピーされました。" + }, + "cache": { + "section_title": "アラート톡 発送内容 キャッシュ", + "section_description": "アラート톡は発送する際、カカオに登録されたテンプレート内容(本文・ボタン)をそのまま送信する必要があります。発送するたびにカカオに内容をリクエストしないよう、一度取得した内容を一定時間再利用(キャッシュ)します。これにより、発送が多くてもカカオ確認の制限に引っかからず、素早く発送されます。" + }, + "tabs": { + "connection": "環境設定", + "templates": "アラート톡 テンプレート" + }, + "preparation": { + "intro": "文字・カカオ アラート톡を発送するには、まず Biznorio コンソールで以下の事前準備を完了する必要があります。", + "sms_label": "文字(SMS/LMS)", + "sms_sender": "発信番号を登録してください。", + "kakao_label": "カカオ アラート톡", + "kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。", + "kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。", + "kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。", + "console_link": "Biznorio コンソールを開く" + } + }, + "binding": { + "section_title": "カカオ アラート톡 連動", + "list_guide": "各 通知に発送するカカオ アラート톡 テンプレートを接続してください。[接続]で承認されたテンプレートを指定すると、該当イベント発生時にアラート톡が自動発送されます。", + "section_hint": "承認されたアラート톡 テンプレートのみ接続できます。保存するとこの 通知に即座に反映されます。", + "modal_title": "アラート톡 接続 · {name}", + "unbound": "未接続", + "unavailable": "使用不可 — 再接続が必要", + "btn_connect": "接続", + "btn_change": "接続を変更", + "fallback_on": "SMS 代替 ON", + "fallback_off": "SMS 代替 OFF", + "connected_template": "接続テンプレート", + "none": "接続しない", + "no_approved_templates": "発送可能な(承認された)アラート톡 テンプレートがありません。まずテンプレートを登録・検査してください。", + "templates_load_failed": "テンプレート リストを読み込みできませんでした。プラグイン 設定(認証情報)を確認してください。", + "fallback_sms": "失敗時 SMS で代替発送", + "fallback_hint": "アラート톡 発送が失敗すると、この 通知の本文内容が文字(SMS)で代わりに発送されます。", + "variables_hint": "提供変数(発送時に自動置換)", + "saved": "アラート톡 連動を保存しました。", + "save_error": "アラート톡 連動 保存に失敗しました。" + }, + "banner": { + "not_ready": "発送に必要な 設定が完了していません。", + "setup_action": "設定する", + "test_mode": "検査モードです — 実際の発送は行われません。" + }, + "dispatch_result": { + "column_header": "文字・アラート톡 結果", + "inspection_label": "検査", + "low_balance": "残高不足", + "fallback": "SMS 代替発送 {status}", + "detail_title": "文字・アラート톡 発送 結果", + "channel_label": "発送チャネル: {channel}", + "sent_content_label": "実際の発送内容", + "sent_content_hint": "アラート톡 はカカオ承認テンプレートの実際の内容で発送され、上記の\"本文\"と異なる場合があります。" + }, + "editor": { + "data_source": { + "dispatch_results": "ビズプリオ送信結果" + } + }, + "templates": { + "title": "アラームトークテンプレート", + "description": "カカオアラームトークテンプレートを登録·審査·管理します。承認されたテンプレートのみアラーム連動に使用できます。", + "readiness": { + "title": "アラームトークテンプレートを使用するには、以下の設定が必要です", + "go_settings": "環境設定に移動", + "missing_label": "未設定項目", + "api_key_missing": "カカオ管理API キー", + "sender_key_missing": "アラームトーク送信プロフィールキー", + "note": "上記項目を環境設定タブで入力すると、テンプレート照会·登録が可能になります。実際の送信は審査モードをオフにして運用に切り替えた後、カカオ承認を受けたテンプレートのみ使用できます。" + }, + "list_error": { + "title": "テンプレート一覧を読み込めませんでした" + }, + "list_notice": { + "console_desc": "テンプレートの登録·編集·審査はビズプリオコンソールで進めてください。", + "console_link": "ビズプリオコンソールを開く" + }, + "list": { + "refresh": "更新", + "search": "検索", + "search_placeholder": "テンプレート名検索(2~50文字)", + "filter_all": "すべてのステータス", + "empty": "登録されたアラームトークテンプレートがありません。", + "empty_hint": "ビズプリオコンソールで登録したテンプレートがここに表示されます。", + "load_failed": "テンプレート一覧を読み込めませんでした。送信プロフィールキーとAPI キーを確認してください。", + "columns": { + "no": "番号", + "name": "テンプレート名", + "code": "コード", + "status": "ステータス", + "requested_at": "登録申請日", + "processed_at": "処理日", + "actions": "内容" + } + }, + "status": { + "sendable": "送信可能", + "inspecting": "審査中", + "rejected": "却下", + "uninspected": "未審査", + "stopped": "停止", + "blocked": "ブロック", + "dormant": "休止", + "unknown": "不明" + }, + "status_sub": { + "rdy": "(使用前)" + }, + "status_guide": { + "title": "ステータスバッジ", + "sendable_label": "送信可能", + "sendable": "承認完了。このステータスのみアラームに接続·送信できます。", + "inspecting_label": "審査中", + "inspecting": "カカオ審査進行中(営業日2~3日)。", + "pending_label": "未審査·却下", + "pending": "まだ送信できません。コンソールで審査申請·修正してください。" + }, + "link_type": { + "WL": "ウェブリンク", + "AL": "アプリリンク", + "DS": "配送追跡", + "BK": "ボットキーワード", + "MD": "メッセージ転送", + "AC": "チャネル追加", + "BC": "相談トーク転換", + "BT": "ボット転換", + "TN": "電話をかける", + "MP": "地図表示", + "P1": "画像セキュア送信", + "P2": "個人情報利用", + "P3": "ワンクリック決済" + }, + "actions": { + "detail": "詳細" + }, + "detail": { + "title": "テンプレート詳細", + "close": "閉じる", + "buttons": "ボタン", + "extra": "付加情報", + "category": "カテゴリ", + "code": "テンプレートコード", + "content": "テンプレート内容", + "emphasize_type": "テンプレート種別", + "image_upload": "画像添付", + "subtitle_field": "補助文句", + "title_field": "強調表記文句", + "type_image": "画像形", + "type_none": "基本形", + "type_text": "強調表記形" + } + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json new file mode 100644 index 00000000..942bb45f --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json @@ -0,0 +1,30 @@ +{ + "identifier": "g7-plugin-sirsoft-message_bizppurio-ja", + "namespace": "g7", + "vendor": "sirsoft", + "name": { + "ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩", + "en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack", + "ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック" + }, + "description": { + "ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩 (번들)", + "en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack (bundled)", + "ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック(バンドル)" + }, + "version": "1.0.0", + "license": "MIT", + "scope": "plugin", + "target_identifier": "sirsoft-message_bizppurio", + "locale": "ja", + "locale_name": "Japanese", + "locale_native_name": "日本語", + "text_direction": "ltr", + "g7_version": ">=7.0.0-beta.4", + "requires": { + "target_version": null, + "depends_on_core_locale": true + }, + "github_url": "", + "github_changelog_url": "" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json new file mode 100644 index 00000000..8d37fc29 --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json @@ -0,0 +1,4 @@ +{ + "name": "Bizppurio メッセージ発送", + "description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。" +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json new file mode 100644 index 00000000..950f445d --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json @@ -0,0 +1,18 @@ +{ + "bizppurio_balance_low": { + "definition": { + "name": "Bizssprrio残高不足", + "description": "Bizssprrio ウォレット残高が不足しており、SMS/アラートトーク送信に失敗した場合、管理者に送信されます" + }, + "templates": { + "mail": { + "subject": "[{app_name}] Bizssprrio 残高が不足しています", + "body": "{name}様、Bizssprrio ウォレット残高が不足しているため、{channel_label} 送信に失敗しました (コード: {result_code})。チャージ後、送信が正常化されます。" + }, + "database": { + "subject": "Bizssprrio残高不足", + "body": "Bizssprrio残高不足により、{channel_label} 送信に失敗しました (コード: {result_code})。" + } + } + } +} diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json new file mode 100644 index 00000000..db0ca18e --- /dev/null +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json @@ -0,0 +1,18 @@ +{ + "sirsoft-message_bizppurio": { + "name": "ビズプリオ メッセージ発送", + "description": "ビズプリオ メッセージ発送プラグインが提供する権限" + }, + "sirsoft-message_bizppurio.messaging": { + "name": "メッセージ発送", + "description": "メッセージ発送ドメイン権限 (閲覧·管理)" + }, + "sirsoft-message_bizppurio.messaging.view": { + "name": "メッセージ閲覧", + "description": "発送履歴·アラートトークテンプレート閲覧 (モニタリング)" + }, + "sirsoft-message_bizppurio.messaging.manage": { + "name": "メッセージ管理", + "description": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理" + } +} From f2c11d2edb5cda963594ac1895fb25870ab63b4c Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 09:39:28 +0900 Subject: [PATCH 25/34] =?UTF-8?q?fix(message=5Fbizppurio):=20webhook=20?= =?UTF-8?q?=EB=AF=B8=EB=93=A4=EC=9B=A8=EC=96=B4=20self-gate=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20+=20=EB=B0=9C=EC=86=A1=20=EC=8B=9C=EB=82=98?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20scale=20=EB=A9=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라우트 파일에서 직접 부착하던 webhook IP 화이트리스트를 plugin.php getMiddleware self-gate 선언으로 전환한다(코어 확장 미들웨어 규정). 테스트 게이트 활성 헬퍼를 이식해 webhook 보안 테스트를 복구하고, 단건 발송 Job 시나리오에 scale 축 면제를 명시한다(볼륨 무관 상수 메모리). webhook API 문서에 self-gate 부착 방식을 함께 보강한다. --- .../docs/api/webhook.md | 2 +- .../sirsoft-message_bizppurio/plugin.php | 23 ++++++++++++ .../src/routes/api.php | 3 +- .../tests/PluginTestCase.php | 36 +++++++++++++++++++ .../scenarios/dispatch-history-webhook.yaml | 1 + 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md index ad7d5e9b..f8cea569 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md @@ -78,7 +78,7 @@ Content-Type: application/json 비즈뿌리오가 문자·알림톡 발송 결과를 URL PUSH 로 통보하는 리포트 수신 엔드포인트다. 운영자가 이 주소를 비즈뿌리오에 등록하면(환경설정 화면의 리포트 수신 주소), 발송 후 결과가 이 엔드포인트로 전송된다. -- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403. +- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403. IP 화이트리스트는 `plugin.php::getMiddleware()` 에서 이 라우트명(`api.plugins.sirsoft-message_bizppurio.webhook`)으로 self-gate 선언하며, 코어 게이트가 요청 시점에 부착한다(라우트 파일 직접 부착 아님). - **처리**: `REFKEY` 로 발송 이력을 조회한다. 없으면(위조/미매칭) 200 으로 흡수한다. 이미 리포트가 반영된 이력(`reported_at` 존재)이면 replay 로 판정해 멱등 처리한다. 그 외에는 `RESULT` 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 `media`·`fallback_status`·`raw_payload`·`reported_at` 을 기록한다. - **잔액부족**: `RESULT` 가 9070(문자)/7436(알림톡)이면 이력을 실패로 뒤집고 관리자에게 자체 알림을 1회 발송한다. - 응답은 항상 200 이다(비즈뿌리오가 실패 응답을 재전송하지 않도록). replay 멱등이 중복 처리를 막는다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index ddd6f56b..4e4923f2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -12,6 +12,7 @@ use App\Models\NotificationDefinition; use Database\Seeders\NotificationDefinitionSeeder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; @@ -450,4 +451,26 @@ class Plugin extends AbstractPlugin ValidateBizppurioSettingsListener::class, ]; } + + /** + * 확장 미들웨어 선언 (self-gate) + * + * webhook(URL PUSH) 리포트 수신 엔드포인트에만 IP 화이트리스트를 부착한다. + * 라우트 파일에서 직접 부착하지 않고 코어 게이트(ExtensionMiddlewareGate)가 + * 요청 시점에 라우트 이름을 대조해 매칭될 때만 실행한다. + * + * @return array> + */ + public function getMiddleware(): array + { + return [ + [ + 'class' => BizppurioWebhookIpWhitelist::class, + 'groups' => ['api'], + 'targets' => [ + 'api.plugins.sirsoft-message_bizppurio.webhook', + ], + ], + ]; + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 78df0822..63dfb6a5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -8,7 +8,6 @@ use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateControlle use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController; use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController; use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController; -use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; /* |-------------------------------------------------------------------------- @@ -24,9 +23,9 @@ use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist // // api 그룹에 appendToGroup 된 토큰/IDV 미들웨어를 라우트 레벨에서 제외(코어 무수정)하고, // 인증은 IP 화이트리스트로 대체한다(계획서 D13). 항상 200 응답(멱등). +// IP 화이트리스트는 plugin.php::getMiddleware() 에서 이 라우트명으로 self-gate 선언한다. Route::post('/webhook', [BizppurioWebhookController::class, 'handle']) ->withoutMiddleware([EnforceIdentityPolicy::class, RefreshTokenExpiration::class]) - ->middleware(BizppurioWebhookIpWhitelist::class) ->name('webhook'); Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php index da7b1562..e957d824 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php @@ -2,8 +2,12 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests; +use App\Enums\ExtensionStatus; +use App\Extension\ExtensionMiddlewareRegistry; use App\Extension\HookListenerRegistrar; use App\Extension\HookManager; +use App\Extension\PluginManager; +use App\Models\Plugin; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; @@ -54,6 +58,38 @@ abstract class PluginTestCase extends TestCase $this->isolatePluginStorage(); $this->snapshotHookManager(); $this->snapshotHookListenerRegistrar(); + $this->activateSelfForMiddlewareGate(); + } + + /** + * 자기 플러그인을 미들웨어 게이트 인덱스에 활성 등록한다. + * + * getMiddleware() self-gate 선언(webhook IP 화이트리스트)은 코어 게이트가 + * 활성 플러그인 registry 를 대조해 실행하므로, 테스트 환경에서도 plugins 테이블 + * 활성 행 + PluginManager 인스턴스 등록 + 인덱스 무효화가 있어야 매칭된다. + * 누락 시 게이트가 미들웨어를 부착하지 못해 차단 IP 도 통과한다. + */ + protected function activateSelfForMiddlewareGate(): void + { + Plugin::query()->updateOrCreate( + ['identifier' => 'sirsoft-message_bizppurio'], + [ + 'vendor' => 'sirsoft', + 'name' => json_encode(['ko' => 'sirsoft-message_bizppurio', 'en' => 'sirsoft-message_bizppurio']), + 'version' => '1.0.0', + 'status' => ExtensionStatus::Active->value, + ] + ); + PluginManager::invalidatePluginStatusCache(); + + $pluginManager = $this->app->make(PluginManager::class); + $property = new \ReflectionProperty($pluginManager, 'plugins'); + $property->setAccessible(true); + $plugins = $property->getValue($pluginManager); + $plugins['sirsoft-message_bizppurio'] = new \Plugins\Sirsoft\MessageBizppurio\Plugin; + $property->setValue($pluginManager, $plugins); + + ExtensionMiddlewareRegistry::flush(); } /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml index 6f22db53..9d04e6b5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml @@ -1,3 +1,4 @@ +# audit:allow scenario-scale-axis-required reason: SendMessageJob 은 알림 1건당 1 dispatch(payload 1건·refkey 1건)로 볼륨 무관 상수 메모리다. 루프/chunk/대량 적재가 없어 #79 형 붕괴 구조가 아니므로 scale 축이 무의미하다. # audit:allow test-scenario-coverage reason: | # 발송 결과 기록·webhook 수신·결과코드 해석의 axes cross product 는 PHPUnit # (BizppurioWebhookTest·ResultCodeResolverTest·SmsChannelDriverTest·SendMessageJobTest· From a1b30fd21863a4eac10277e12cc9bfbc4b381512 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 09:56:59 +0900 Subject: [PATCH 26/34] =?UTF-8?q?docs(message=5Fbizppurio):=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20=EC=B1=84=EB=84=90=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20CHANGELOG=20=EA=B8=B0=EC=9E=AC=20+=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=ED=86=A1=20=EC=97=B0=EB=8F=99=20=EC=A0=80=EC=9E=A5=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EC=A3=BC=EC=84=9D=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플러그인 삭제 시 문자·알림톡 채널이 정리되고 재설치 시 복원된다는 사용자 관점 항목을 CHANGELOG 에 추가한다. 알림톡 연동 저장 방식이 "코어 저장 훅 편승"에서 "행 하단 [연결/변경] 버튼 → 우리 연결 모달 → 우리 API 직접 저장"으로 확정됐으나 일부 주석이 옛 설명으로 남아 있어, 실제 구현과 일치하도록 3곳을 정정한다(로직 불변). --- .../sirsoft-message_bizppurio/CHANGELOG.md | 1 + .../RegisterNotificationChannelsListener.php | 6 +++--- .../Services/NotificationBindingService.php | 21 ++++++++++--------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 020423cb..c3927f55 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -35,6 +35,7 @@ - 검수 모드로 발송된 문자·알림톡은 발송 이력 화면에 "발송중" 대신 "검수" 라벨로 표시되어, 실제 장애와 검수 도메인 특성을 운영자가 쉽게 구분할 수 있습니다. - 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다. - 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다. +- 플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. ### Changed diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 95983d24..3754868a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -25,9 +25,9 @@ use App\Services\SettingsService; * ChannelManager 에 등록되어야 발화하므로, 드라이버 등록은 ServiceProvider::boot() 가 * 담당한다(이 리스너는 채널 "노출·판정"만 책임진다). * - * 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 사용한다(Phase 6 재설계, 계획서 ⚑⚑ 블록 A). - * 연결 템플릿·SMS 대체 등 알림톡 전용 설정은 코어 편집 모달에 전용 칸을 overlay 로 얹고 - * 코어 저장 훅(core.notification_template.filter_update_data/after_update)에 편승해 저장하므로, + * 알림톡 탭은 코어 기본 목록을 그대로 사용한다(Phase 6 재설계, 계획서 ⚑⚑ 블록 A). + * 연결 템플릿·SMS 대체 등 알림톡 전용 설정은 코어 목록 행 하단에 overlay 로 얹은 + * [연결/변경] 버튼 → 우리 연결 모달 → 우리 API(notification-bindings)로 직접 저장하므로, * 이 리스너는 코어 목록을 숨기는 별도 플래그를 두지 않는다. */ class RegisterNotificationChannelsListener implements HookListenerInterface diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php index edf21004..6bdf291f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php @@ -11,13 +11,13 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificatio /** * 이벤트↔알림톡 템플릿 연동(binding) 서비스 (계획서 §6-2, Phase 6 재설계 A). * - * 알림 설정 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 사용한다(⚑⚑ 결정 A). 연결 템플릿· - * SMS 대체는 코어 편집 모달에 얹은 전용 칸에서 입력하고, 코어 [저장] 훅 - * (core.notification_template.filter_update_data/after_update)에 편승해 이 서비스의 - * bind/unbind 로 저장된다(별도 저장 버튼·우리 목록 API 없음). + * 알림 설정 알림톡 탭은 코어 기본 목록을 그대로 사용한다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체는 + * 코어 목록 행 하단에 얹은 [연결/변경] 버튼 → 우리 연결 모달에서 입력하고, 우리 API + * (POST notification-bindings)로 이 서비스의 bind/unbind 를 직접 호출해 저장된다 + * (코어 저장 버튼과 무관). * - * 이 서비스는 (1) 코어 편집 모달 전용 칸이 소비하는 조회 — 승인 템플릿 드롭다운(approvedTemplates) - * 과 현재 연동 맵(all) — 및 (2) 저장 훅 리스너가 호출하는 bind/unbind 를 제공한다. + * 이 서비스는 (1) 연결 모달이 소비하는 조회 — 승인 템플릿 드롭다운(approvedTemplates) + * 과 현재 연동 맵(all) — 및 (2) 연결 모달 저장이 호출하는 bind/unbind 를 제공한다. * * 연결 대상 템플릿 드롭다운은 "발송 가능(승인) 상태" 템플릿만 노출한다 — 미승인 템플릿에 * 연동해도 발송이 거부되기 때문. 승인 판정은 AlimtalkTemplateService 의 배지 매핑(RDY/ACT @@ -173,11 +173,12 @@ class NotificationBindingService } /** - * 코어 편집 모달 전용 칸이 넘긴 값으로 연동을 반영합니다 (저장 훅 편승). + * 연결 모달이 넘긴 값으로 연동을 반영합니다. * - * 코어 알림 템플릿 [저장] 훅(after_update)에서 호출된다. 연결 템플릿 코드가 비어 있으면 - * 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제" 규칙 덕분에 편집 모달에서 드롭다운을 - * "연결 안 함"으로 바꾸고 저장하면 코어 저장 한 번으로 해제까지 처리된다. + * 우리 연결 저장 API(NotificationBindingController::store → POST notification-bindings)에서 + * 호출된다. 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제" + * 규칙 덕분에 연결 모달에서 드롭다운을 "연결 안 함"으로 바꾸고 저장하면 저장 한 번으로 + * 해제까지 처리된다. * * @param string $notificationType 코어 notification_definitions.type * @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제) From f49d939ec1dfceac086f5910e7bc0657c22b271e Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 10:17:22 +0900 Subject: [PATCH 27/34] =?UTF-8?q?chore(message=5Fbizppurio):=20A-2=20?= =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EA=B2=80=EC=88=98?= =?UTF-8?q?=EC=9A=A9=20=EB=8D=B0=EB=AA=A8=20=EC=8B=9C=EB=8D=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DispatchResultDemoSeeder 는 코어 알림 발송 이력 화면의 비즈뿌리오 결과 컬럼·토글 노출을 눈으로 확인하기 위한 일회성 검수용 시더였다. 검수가 끝나 공개 릴리스에 데모 데이터 생성기가 포함되지 않도록 제거한다. --- .../seeders/DispatchResultDemoSeeder.php | 184 ------------------ 1 file changed, 184 deletions(-) delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php b/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php deleted file mode 100644 index 0d47269b..00000000 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php +++ /dev/null @@ -1,184 +0,0 @@ -clear(); - - $bizppurioCases = $this->bizppurioCases(); - $nonBizppurioCases = $this->nonBizppurioCases(); - - foreach ($bizppurioCases as $case) { - $log = $this->createLog($case, hasDispatch: true); - $this->createDispatch($case, $log->id); - } - - foreach ($nonBizppurioCases as $case) { - // 비-비즈뿌리오(mail/database)는 dispatch 를 만들지 않는다(결과 매칭 없음 확인용). - $this->createLog($case, hasDispatch: false); - } - - $total = count($bizppurioCases) + count($nonBizppurioCases); - $this->command?->info('[A-2 데모] 코어 알림 로그 '.$total.'건 삽입 완료 (비즈뿌리오 '.count($bizppurioCases).' + 비-비즈뿌리오 '.count($nonBizppurioCases).').'); - $this->command?->info('[A-2 데모] 전체/SMS/알림톡 탭 = 결과 컬럼 노출, 메일/사이트내알림 탭 = 결과 컬럼(헤더 포함) 숨김을 확인하세요.'); - $this->command?->info('[A-2 데모] 정리: DispatchResultDemoSeeder::clear() — source="'.self::SOURCE_MARKER.'" 로그와 연결 dispatch 만 삭제됩니다.'); - } - - /** - * 비즈뿌리오 발송(sms/lms/alimtalk) 케이스 — 4가지 상태 + 엣지 케이스 망라. - * - * @return array> - */ - private function bizppurioCases(): array - { - return [ - // ── SMS ──────────────────────────────────────────────────────────── - ['channel' => 'sms', 'type' => 'welcome', 'name' => '홍길동', 'phone' => '01011110001', 'member' => true, 'status' => 'success', 'code' => '4100', 'fallback' => null], - ['channel' => 'sms', 'type' => 'order_shipped', 'name' => '김철수', 'phone' => '01011110002', 'member' => true, 'status' => 'failed', 'code' => '4400', 'fallback' => null], // 음영 지역 - ['channel' => 'sms', 'type' => 'reset_password', 'name' => '이수정', 'phone' => '01011110003', 'member' => true, 'status' => 'failed', 'code' => '4410', 'fallback' => null], // 잘못된 번호 - ['channel' => 'sms', 'type' => 'order_confirmed', 'name' => '박영호', 'phone' => '01011110004', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], // 발송요청 성공, 리포트 대기(코드 없음) - ['channel' => 'sms', 'type' => 'welcome', 'name' => '정지원', 'phone' => '01011110005', 'member' => true, 'status' => 'pending', 'code' => null, 'fallback' => null], // 발송중(pending) - ['channel' => 'sms', 'type' => 'order_confirmed', 'name' => null, 'phone' => '01011110006', 'member' => false, 'status' => 'success', 'code' => '4100', 'fallback' => null], // 비회원(수신자 링크 없음) - ['channel' => 'sms', 'type' => 'order_cancelled', 'name' => '최민아', 'phone' => '01011110007', 'member' => true, 'status' => 'failed', 'code' => '9070', 'fallback' => null], // 잔액 부족(문자) - ['channel' => 'sms', 'type' => 'order_shipped', 'name' => '강대현', 'phone' => '01011110008', 'member' => true, 'status' => 'failed', 'code' => '9999', 'fallback' => null], // 미정의 코드(사유 없이 코드만) - - // ── LMS ──────────────────────────────────────────────────────────── - ['channel' => 'lms', 'type' => 'order_confirmed', 'name' => '윤서연', 'phone' => '01011110009', 'member' => true, 'status' => 'success', 'code' => '6600', 'fallback' => null], // LMS 성공 - ['channel' => 'lms', 'type' => 'order_delivered', 'name' => '임재훈', 'phone' => '01011110010', 'member' => true, 'status' => 'failed', 'code' => '6603', 'fallback' => null], // LMS 음영 지역 - - // ── 알림톡 ────────────────────────────────────────────────────────── - ['channel' => 'alimtalk', 'type' => 'order_confirmed', 'name' => '한지민', 'phone' => '01011110011', 'member' => true, 'status' => 'success', 'code' => '7000', 'fallback' => null], // 알림톡 성공 - ['channel' => 'alimtalk', 'type' => 'reset_password', 'name' => '오세훈', 'phone' => '01011110012', 'member' => true, 'status' => 'success', 'code' => '7000', 'fallback' => '성공'], // 대체발송 성공 - ['channel' => 'alimtalk', 'type' => 'order_shipped', 'name' => '신유진', 'phone' => '01011110013', 'member' => true, 'status' => 'failed', 'code' => '7436', 'fallback' => '실패'], // 잔액 부족 + 대체발송 실패 - ['channel' => 'alimtalk', 'type' => 'welcome', 'name' => '배준영', 'phone' => '01011110014', 'member' => true, 'status' => 'failed', 'code' => '7206', 'fallback' => null], // 검수 안 된 템플릿 - ['channel' => 'alimtalk', 'type' => 'order_confirmed', 'name' => null, 'phone' => '01011110015', 'member' => false, 'status' => 'success', 'code' => '7000', 'fallback' => null], // 비회원 알림톡 - ['channel' => 'alimtalk', 'type' => 'password_changed', 'name' => '문가영', 'phone' => '01011110016', 'member' => true, 'status' => 'pending', 'code' => null, 'fallback' => null], // 알림톡 발송중 - ]; - } - - /** - * 비-비즈뿌리오 발송(mail/database) 케이스 — 결과 컬럼 숨김 확인용(dispatch 없음). - * - * @return array> - */ - private function nonBizppurioCases(): array - { - return [ - ['channel' => 'mail', 'type' => 'welcome', 'name' => '홍보람', 'phone' => 'boram@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], - ['channel' => 'mail', 'type' => 'reset_password', 'name' => '서다인', 'phone' => 'dain@example.com', 'member' => true, 'status' => 'failed', 'code' => null, 'fallback' => null], - ['channel' => 'mail', 'type' => 'password_changed', 'name' => '남기훈', 'phone' => 'gihun@example.com', 'member' => true, 'status' => 'skipped', 'code' => null, 'fallback' => null], - ['channel' => 'database', 'type' => 'new_comment', 'name' => '조은채', 'phone' => 'eunchae@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], - ['channel' => 'database', 'type' => 'order_confirmed', 'name' => '권도윤', 'phone' => 'doyoon@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], - ]; - } - - /** - * 코어 알림 로그 1건을 생성합니다. - * - * @param array $case 케이스 정의 - * @param bool $hasDispatch 비즈뿌리오 발송 여부(로그 본문 문구용) - * @return NotificationLog 생성된 로그 - */ - private function createLog(array $case, bool $hasDispatch): NotificationLog - { - // 코어 로그 status 는 sent/failed/skipped 만 있으므로, dispatch 의 pending/sent/success 는 sent 로 맵. - $logStatus = match ($case['status']) { - 'failed' => 'failed', - 'skipped' => 'skipped', - default => 'sent', - }; - - return NotificationLog::create([ - 'channel' => $case['channel'], - 'notification_type' => $case['type'], - 'extension_type' => 'core', - 'extension_identifier' => 'core', - 'recipient_identifier' => $case['phone'], - 'recipient_name' => $case['name'], - 'subject' => $case['channel'] === 'mail' ? '[A-2 데모] '.$case['type'].' 메일' : null, - 'body' => '[A-2 데모] '.$case['type'].' 발송 본문 샘플'.($hasDispatch ? ' (비즈뿌리오)' : ''), - 'status' => $logStatus, - 'source' => self::SOURCE_MARKER, - 'sent_at' => now(), - ]); - } - - /** - * 코어 로그에 연결된 비즈뿌리오 dispatch 1건을 생성합니다. - * - * @param array $case 케이스 정의 - * @param int $notificationLogId 연결할 코어 로그 id - */ - private function createDispatch(array $case, int $notificationLogId): void - { - BizppurioDispatch::create([ - 'refkey' => 'demo_'.Str::random(26), - 'channel' => $case['channel'], - 'to_number' => $case['phone'], - 'to_name' => $case['name'], - 'to_user_id' => null, // 데모는 실제 회원 FK 연결 없음(회원/비회원 구분은 코어 로그 recipient 로 표현) - 'content' => '[A-2 데모] 발송 본문 샘플', - 'notification_type' => $case['type'], - 'notification_log_id' => $notificationLogId, - 'status' => $case['status'], - 'result_code' => $case['code'], - 'result_message' => null, - 'fallback_status' => $case['fallback'], - 'source' => 'auto', - 'sent_at' => now(), - 'reported_at' => in_array($case['status'], ['success', 'failed'], true) ? now() : null, - ]); - } - - /** - * 데모 삽입분만 삭제합니다 (source 마커 기준). - * - * 코어 로그를 먼저 찾아 그 id 로 연결된 dispatch 를 지운 뒤 로그를 지운다. 실제 발송 데이터는 - * 이 마커를 갖지 않으므로 영향받지 않는다. - */ - public function clear(): void - { - $logIds = NotificationLog::query()->where('source', self::SOURCE_MARKER)->pluck('id')->all(); - - if ($logIds !== []) { - BizppurioDispatch::query()->whereIn('notification_log_id', $logIds)->delete(); - NotificationLog::query()->whereIn('id', $logIds)->delete(); - } - - $this->command?->info('[A-2 데모] 데모 로그·dispatch '.count($logIds).'건을 정리했습니다.'); - } -} From 4b5ec6ebd693fc36a0424093ca2fed561fb9befc Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 12:15:10 +0900 Subject: [PATCH 28/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EC=9E=94?= =?UTF-8?q?=EC=95=A1=EB=B6=80=EC=A1=B1=20=EC=95=8C=EB=A6=BC=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=20=EB=B3=80=EC=88=98=20=EB=AF=B8=EC=B9=98=ED=99=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20+=20=ED=9B=84=EB=B6=88=20=ED=95=9C?= =?UTF-8?q?=EB=8F=84=EC=B4=88=EA=B3=BC=20=EB=8C=80=EC=9D=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 잔액부족 관리자 알림 본문의 {channel_label}·{result_code} 등이 실제 값으로 치환되지 않고 중괄호째 노출되던 결함을 수정한다. balance.low 훅 인자와 사이트 메타를 알림 data 로 채우는 extract_data 구독 리스너(BalanceLowNotificationDataListener) 를 추가해, 코어 알림 본문 치환이 성립하도록 한다. 후불 한도 초과(9071)를 잔액부족과 동일 분류에 포함해 관리자 알림이 발화되도록 하고, 채널군 라벨(문자/알림톡)·결과코드 사유를 ko/en 및 ja 언어팩에 추가한다. --- .../CHANGELOG.md | 2 + .../backend/ja/messages.php | 4 + .../backend/ja/result_codes.php | 1 + .../sirsoft-message_bizppurio/CHANGELOG.md | 1 + .../lang/en/messages.php | 6 + .../lang/en/result_codes.php | 3 +- .../lang/ko/messages.php | 6 + .../lang/ko/result_codes.php | 3 +- .../sirsoft-message_bizppurio/plugin.php | 2 + .../BalanceLowNotificationDataListener.php | 110 +++++++++++++++ .../src/Services/ResultCodeResolver.php | 8 +- ...BalanceLowNotificationDataListenerTest.php | 127 ++++++++++++++++++ .../Unit/Services/ResultCodeResolverTest.php | 13 +- 13 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md index d4ec2274..75a430e1 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -10,3 +10,5 @@ - 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공 - 환경설정·알림톡 템플릿 관리·발송 이력 등 관리자 화면 텍스트와 발송 결과 코드 안내의 일본어 번역 추가 — 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. +- 후불 한도 초과(9071) 결과 코드 사유의 일본어 번역 추가. +- 잔액부족 알림의 채널군 라벨(문자/알림톡) 일본어 번역 추가. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php index b021ed3c..ca0770ef 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -72,4 +72,8 @@ return [ 'cache' => [ 'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。', ], + 'channel_group' => [ + 'text' => '文字', + 'alimtalk' => '通知トーク', + ], ]; diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php index b7682ea9..13b492a0 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php @@ -46,4 +46,5 @@ return [ '7523' => 'その他のエラー', '9000' => '一時的なシステムエラー', '9070' => '残高不足(メール)', + '9071' => '後払い限度額超過', ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index c3927f55..63f03b92 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -36,6 +36,7 @@ - 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다. - 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다. - 플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. +- 잔액 부족으로 발송이 실패해 관리자에게 보내는 알림에서, 실패한 채널(문자/알림톡)과 결과 코드가 안내 문구에 실제 값으로 표시됩니다. 후불 결제 한도를 초과한 경우도 잔액 부족과 동일하게 관리자에게 알립니다. ### Changed diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index f901b492..d6281577 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -8,6 +8,12 @@ return [ 'alimtalk' => 'Alimtalk', ], + // 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용) + 'channel_group' => [ + 'text' => 'SMS', + 'alimtalk' => 'Alimtalk', + ], + // 발송 상태 (DispatchStatus enum) 'status' => [ 'pending' => 'Pending', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php index 7d83d64f..42c6b7b1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -9,7 +9,7 @@ declare(strict_types=1); | | Source: Bizppurio API manual "9. Status_Code" table | As of: 2026-07-13 (plan appendix C-3, phase 1 scope) -| Scope: common(2000~9070) + SMS(4100~4443) + LMS(6600~6641) + Alimtalk(7000~7523). +| Scope: common(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + Alimtalk(7000~7523). | RCS(8000s)/NaverTalkTalk(part of 5000s) are out of scope (follow-up D15). | */ @@ -32,6 +32,7 @@ return [ '5005' => 'Temporary delivery error', '9000' => 'Temporary system error', '9070' => 'Insufficient balance (SMS)', + '9071' => 'Postpaid limit exceeded', // ── SMS report ─────────────────────────────────────── '4100' => 'Success', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index a1fc0b42..df9b94eb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -8,6 +8,12 @@ return [ 'alimtalk' => '알림톡', ], + // 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용) + 'channel_group' => [ + 'text' => '문자', + 'alimtalk' => '알림톡', + ], + // 발송 상태 (DispatchStatus enum) 'status' => [ 'pending' => '대기', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php index 89ec128c..d6387fc5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -9,7 +9,7 @@ declare(strict_types=1); | | 출처: 비즈뿌리오 API 매뉴얼 "9. Status_Code" 표 | 기준일: 2026-07-13 (계획서 부록 C-3 1차 범위) -| 범위: 공통(2000~9070) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523) +| 범위: 공통(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523) | RCS(8000대)·네이버톡톡(5000대 일부)은 범위 밖(후속 D15). | | 매뉴얼이 삭제되어도 이 파일이 원본 스펙을 보존한다. 새 코드 추가 시 분류는 @@ -35,6 +35,7 @@ return [ '5005' => '일시적 전송 오류', '9000' => '일시적 시스템 오류', '9070' => '잔액 부족(문자)', + '9071' => '후불 한도 초과', // ── SMS 리포트 ──────────────────────────────────────── '4100' => '성공', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 4e4923f2..4450cd31 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -13,6 +13,7 @@ use Database\Seeders\NotificationDefinitionSeeder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; +use Plugins\Sirsoft\MessageBizppurio\Listeners\BalanceLowNotificationDataListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; @@ -447,6 +448,7 @@ class Plugin extends AbstractPlugin RegisterNotificationChannelsListener::class, SeedChannelTemplatesListener::class, GuestPhoneExtractListener::class, + BalanceLowNotificationDataListener::class, LinkNotificationLogListener::class, ValidateBizppurioSettingsListener::class, ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php new file mode 100644 index 00000000..996492f6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php @@ -0,0 +1,110 @@ +> + */ + public static function getSubscribedHooks(): array + { + return [ + 'sirsoft-message_bizppurio.notification.extract_data' => [ + 'method' => 'injectBalanceLowData', + 'priority' => 10, + 'type' => 'filter', + ], + ]; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 잔액부족 알림의 data 에 본문 치환 변수를 채웁니다. + * + * extract_data 결과(`{notifiable, notifiables, data, context}`)를 받아, 대상이 + * 잔액부족 알림이면 훅 인자(결과코드·채널)와 사이트 메타를 data 에 덧붙여 반환한다. + * 그 외 유형은 원본을 그대로 통과시킨다. `name` 은 코어가 수신자별로 치환하도록 + * `{recipient_name}` placeholder 를 넣는다(NotificationHookListener 폴백 규약). + * + * @param array $result extract_data 결과 + * @param string $type 알림 정의 유형 + * @param array $args 훅 원본 인수 ([$resultCode, $channel]) + * @return array 변수가 채워진(또는 원본) 결과 + */ + public function injectBalanceLowData(array $result, string $type, array $args): array + { + if ($type !== self::NOTIFICATION_TYPE) { + return $result; + } + + $resultCode = (string) ($args[0] ?? ''); + $channel = (string) ($args[1] ?? ''); + + $result['data'] = array_merge( + $result['data'] ?? [], + [ + 'name' => '{recipient_name}', + 'app_name' => (string) config('app.name', ''), + 'result_code' => $resultCode, + 'channel_label' => $this->channelLabel($channel), + 'settings_url' => rtrim((string) config('app.url', ''), '/').self::SETTINGS_PATH, + 'site_url' => (string) config('app.url', ''), + ], + ); + + return $result; + } + + /** + * 채널 문자열을 잔액부족 알림용 채널군 라벨(문자/알림톡)로 변환합니다. + * + * SMS/LMS 는 "문자" 로 묶고 알림톡은 "알림톡" 으로 표기한다. 알 수 없는 채널은 + * 원본 문자열을 그대로 반환한다(치환 실패보다 원본 노출이 안전). + * + * @param string $channel 채널 문자열 (sms/lms/alimtalk) + * @return string 채널군 라벨 + */ + private function channelLabel(string $channel): string + { + $enum = DispatchChannel::tryFrom($channel); + + if ($enum === null) { + return $channel; + } + + return $enum->isText() + ? __('sirsoft-message_bizppurio::messages.channel_group.text') + : __('sirsoft-message_bizppurio::messages.channel_group.alimtalk'); + } +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php index ce003f89..190468bf 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php @@ -34,9 +34,13 @@ class ResultCodeResolver private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437']; /** - * 잔액 부족 코드 — 문자 9070 / 알림톡 7436 (D3 자체 알림 대상). + * 잔액 부족 코드 — 선불 잔액부족 9070 / 알림톡 지갑 잔액부족 7436 / + * 후불 한도초과 9071 (D3 자체 알림 대상). + * + * 셋 다 "잔액·한도 소진으로 발송 불가" 성격이라 동일하게 관리자 자체 알림을 발화한다. + * 표시 사유는 result_codes lang 으로 각각 구분된다(9071 = 후불 한도 초과). */ - private const BALANCE_LOW_CODES = ['9070', '7436']; + private const BALANCE_LOW_CODES = ['9070', '7436', '9071']; /** * 결과 코드를 카테고리로 분류합니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php new file mode 100644 index 00000000..eb099957 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php @@ -0,0 +1,127 @@ +listener = new BalanceLowNotificationDataListener; + } + + private function emptyResult(): array + { + return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []]; + } + + /** + * 문자(sms) 잔액부족: channel_label="문자", result_code 주입 + 사이트 메타 채움. + */ + public function test_injects_variables_for_sms_balance_low(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9070', 'sms'], + ); + + $data = $out['data']; + $this->assertSame('9070', $data['result_code']); + $this->assertSame('문자', $data['channel_label']); + $this->assertSame('{recipient_name}', $data['name']); + $this->assertNotSame('', $data['app_name']); + $this->assertStringContainsString('/admin/plugins/sirsoft-message_bizppurio/settings', $data['settings_url']); + $this->assertArrayHasKey('site_url', $data); + } + + /** + * 알림톡(alimtalk) 잔액부족: channel_label="알림톡", result_code=7436. + */ + public function test_injects_variables_for_alimtalk_balance_low(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['7436', 'alimtalk'], + ); + + $this->assertSame('7436', $out['data']['result_code']); + $this->assertSame('알림톡', $out['data']['channel_label']); + } + + /** + * lms 도 문자군으로 "문자" 라벨. + */ + public function test_lms_maps_to_text_label(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9071', 'lms'], + ); + + $this->assertSame('문자', $out['data']['channel_label']); + $this->assertSame('9071', $out['data']['result_code']); + } + + /** + * 다른 알림 유형은 원본을 그대로 통과(변수 미주입). + */ + public function test_ignores_other_notification_types(): void + { + $result = $this->emptyResult(); + $out = $this->listener->injectBalanceLowData($result, 'order_confirmed', ['9070', 'sms']); + + $this->assertSame($result, $out); + $this->assertArrayNotHasKey('result_code', $out['data']); + } + + /** + * 알 수 없는 채널은 원본 문자열을 라벨로 유지(치환 실패보다 원본 노출). + */ + public function test_unknown_channel_keeps_raw(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9999', 'unknown'], + ); + + $this->assertSame('unknown', $out['data']['channel_label']); + } + + /** + * 주입된 data 로 알림 본문 템플릿의 변수가 실제 값으로 치환된다(엔드투엔드). + */ + public function test_injected_data_resolves_template_body(): void + { + $out = $this->listener->injectBalanceLowData( + $this->emptyResult(), + 'bizppurio_balance_low', + ['9071', 'alimtalk'], + ); + + $body = '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).'; + $rendered = strtr($body, [ + '{channel_label}' => $out['data']['channel_label'], + '{result_code}' => $out['data']['result_code'], + ]); + + $this->assertSame('비즈뿌리오 잔액 부족으로 알림톡 발송이 실패했습니다 (코드: 9071).', $rendered); + $this->assertStringNotContainsString('{', $rendered); + } +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php index 24a5e517..9cdcaa7b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -33,17 +33,26 @@ class ResultCodeResolverTest extends PluginTestCase } /** - * 잔액부족(9070 문자 / 7436 알림톡)은 BalanceLow + isBalanceLow true. + * 잔액부족(9070 문자 / 7436 알림톡 / 9071 후불 한도초과)은 BalanceLow + isBalanceLow true. */ public function test_balance_low_codes(): void { - foreach (['9070', '7436'] as $code) { + foreach (['9070', '7436', '9071'] as $code) { $this->assertSame(ResultCategory::BalanceLow, $this->resolver->categorize($code), "코드 {$code}"); $this->assertTrue($this->resolver->isBalanceLow($code), "코드 {$code}"); $this->assertFalse($this->resolver->isSuccess($code), "코드 {$code}"); } } + /** + * 후불 한도초과(9071) 는 사유 문구가 lang 에 정의되어 코드만 노출되지 않는다. + */ + public function test_postpaid_limit_code_has_reason(): void + { + $this->assertNotNull($this->resolver->reason('9071')); + $this->assertStringContainsString('9071', $this->resolver->label('9071')); + } + /** * 일시오류 코드는 Retry. * From a706422401ffdd5d2e13ea4ea19b23d0031c09a6 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 12:40:21 +0900 Subject: [PATCH 29/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EA=B2=B0=EA=B3=BC=20=EC=BD=94=EB=93=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EB=A5=BC=20=EC=B5=9C=EC=8B=A0=20=EA=B3=B5=EC=8B=9D=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=EB=A1=9C=20=EC=A0=95=ED=95=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발송 결과 코드 사유 문구를 비즈뿌리오 최신 공식 명세 (bizppurio.github.io/response-codes)에 맞춰 정비한다. 인증·계정 계열 코드(3001·3004·3007·3009 등) 설명이 실제 의미와 달라 관리자가 실패 원인을 오해할 수 있던 것을 정정하고, 누락 코드(3003·3010 IP 오류·4431 수신거부· 7103 발신프로필 키 무효)를 추가한다. 재시도 분류에 5002(요청 과다)를 포함한다. ko/en 및 ja 언어팩 사유를 함께 갱신한다. --- .../CHANGELOG.md | 1 + .../backend/ja/result_codes.php | 58 ++++++++++--------- .../sirsoft-message_bizppurio/CHANGELOG.md | 1 + .../lang/en/result_codes.php | 36 +++++++----- .../lang/ko/result_codes.php | 36 +++++++----- .../src/Services/ResultCodeResolver.php | 7 ++- .../Unit/Services/ResultCodeResolverTest.php | 19 ++++-- 7 files changed, 92 insertions(+), 66 deletions(-) diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md index 75a430e1..2f015e2f 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -12,3 +12,4 @@ - 환경설정·알림톡 템플릿 관리·발송 이력 등 관리자 화면 텍스트와 발송 결과 코드 안내의 일본어 번역 추가 — 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. - 후불 한도 초과(9071) 결과 코드 사유의 일본어 번역 추가. - 잔액부족 알림의 채널군 라벨(문자/알림톡) 일본어 번역 추가. +- 발송 결과 코드 사유를 최신 공식 명세에 맞춰 정합화(추가·정정)한 내용을 일본어 번역에 반영. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php index 13b492a0..e16ebad7 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php @@ -2,49 +2,53 @@ return [ '1000' => '成功', - '2000' => 'リクエストパラメータエラー', - '3001' => '必須パラメータが不足しています', - '3002' => 'トークン無効(期限切れ·廃止)', - '3004' => '許可されていないリクエスト', - '3005' => '認証情報が無効です', - '3006' => 'アカウント情報エラー', - '3007' => '発信番号が未登録です', - '3009' => 'メッセージタイプエラー', - '3011' => '一時的な処理遅延', - '3013' => '一時的なシステムエラー', + '2000' => 'メッセージが無効です', + '3001' => '認証情報が無効です(Basic)', + '3002' => 'トークンが無効です(期限切れ·廃止)', + '3003' => 'IPが無効です', + '3004' => 'アカウントが無効です', + '3005' => '認証情報が無効です(Bearer)', + '3006' => 'アカウントが存在しません', + '3007' => 'アカウントパスワードが無効です', + '3009' => 'アカウントが停止状態です', + '3010' => 'アクセス許可IPが一致しません', + '3011' => '不明なエラー(bizppurio)', + '3013' => '完了処理されていないメッセージ', '4100' => '成功', '4400' => '電波の弱い地域', - '4401' => '電源がオフです', - '4402' => 'ストレージ容量超過', - '4410' => '無効な番号', - '4414' => '欠番·停止', - '4420' => 'その他の端末エラー', - '4430' => 'スパム受信拒否', - '4443' => 'その他のエラー', + '4401' => '電源オフ', + '4402' => 'ストレージ超過', + '4410' => '不正な番号', + '4414' => '結番·停止', + '4420' => 'その他端末エラー', + '4430' => 'スパム', + '4431' => '送信制限受信拒否(スパム)', + '4443' => 'スパムブロック', '5003' => '一時的な送信エラー', '5004' => '一時的な送信エラー', '5005' => '一時的な送信エラー', '6600' => '成功', '6603' => '電波の弱い地域', - '6604' => '電源がオフです', - '6606' => '無効な番号', - '6621' => 'メッセージ長が超過しています', + '6604' => '電源オフ', + '6606' => '不正な番号', + '6621' => 'メッセージ長超過', '6641' => 'スパムブロック', '7000' => '成功', + '7103' => '発信プロフィールキーが無効です', '7106' => '削除された発信キー', '7107' => 'ブロックされた発信キー', - '7204' => 'テンプレート不一致', - '7206' => '審査されていないテンプレート', + '7204' => 'メッセージ内容がテンプレートと不一致です', + '7206' => 'シリアルナンバー形式が不一致です', '7306' => 'カカオシステムエラー', '7307' => '処理遅延', '7308' => '電話番号エラー', '7320' => '受信ブロック', - '7325' => '変数長が超過しています', + '7325' => '変数長超過', '7421' => 'タイムアウト', - '7436' => 'ウォレット残高不足(アラームトーク)', - '7437' => 'メッセージ要求失敗', - '7523' => 'その他のエラー', + '7436' => 'ウォレット残高不足(アラートトーク)', + '7437' => 'メッセージリクエスト失敗', + '7523' => '080受信拒否(スパム)', '9000' => '一時的なシステムエラー', - '9070' => '残高不足(メール)', + '9070' => '残高不足(SMS)', '9071' => '後払い限度額超過', ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 63f03b92..28c1dfb6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -37,6 +37,7 @@ - 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다. - 플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. - 잔액 부족으로 발송이 실패해 관리자에게 보내는 알림에서, 실패한 채널(문자/알림톡)과 결과 코드가 안내 문구에 실제 값으로 표시됩니다. 후불 결제 한도를 초과한 경우도 잔액 부족과 동일하게 관리자에게 알립니다. +- 발송 결과 코드의 사유 안내를 비즈뿌리오 최신 공식 명세에 맞춰 정비했습니다. 일부 코드의 설명을 정정하고 누락된 코드(접속 IP 오류, 수신거부, 발신 프로필 키 오류 등)를 추가해, 발송 이력에서 실패 사유가 더 정확히 표시됩니다. ### Changed diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php index 42c6b7b1..48b0e9f0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -7,8 +7,8 @@ declare(strict_types=1); | Bizppurio result code → reason (English) |-------------------------------------------------------------------------- | -| Source: Bizppurio API manual "9. Status_Code" table -| As of: 2026-07-13 (plan appendix C-3, phase 1 scope) +| Source: Bizppurio official response-code docs (https://bizppurio.github.io/response-codes/) +| As of: 2026-07-27 (aligned to the latest official docs; legacy manual wording corrected) | Scope: common(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + Alimtalk(7000~7523). | RCS(8000s)/NaverTalkTalk(part of 5000s) are out of scope (follow-up D15). | @@ -17,16 +17,18 @@ declare(strict_types=1); return [ // ── Common / send response ─────────────────────────── '1000' => 'Success', - '2000' => 'Invalid request parameter', - '3001' => 'Missing required parameter', + '2000' => 'Message is invalid', + '3001' => 'Invalid authentication (Basic)', '3002' => 'Invalid token (expired/revoked)', - '3004' => 'Request not allowed', - '3005' => 'Invalid authentication', - '3006' => 'Account information error', - '3007' => 'Sender number not registered', - '3009' => 'Message type error', - '3011' => 'Temporary processing delay', - '3013' => 'Temporary system error', + '3003' => 'Invalid IP', + '3004' => 'Account is invalid', + '3005' => 'Invalid authentication (Bearer)', + '3006' => 'Account does not exist', + '3007' => 'Invalid account password', + '3009' => 'Account suspended', + '3010' => 'IP not in allowlist', + '3011' => 'Unknown error (Bizppurio)', + '3013' => 'Message not completed', '5003' => 'Temporary delivery error', '5004' => 'Temporary delivery error', '5005' => 'Temporary delivery error', @@ -42,8 +44,9 @@ return [ '4410' => 'Invalid number', '4414' => 'Disconnected/suspended number', '4420' => 'Other device error', - '4430' => 'Spam rejection', - '4443' => 'Other error', + '4430' => 'Spam', + '4431' => 'Delivery-restricted opt-out (spam)', + '4443' => 'Spam blocked', // ── LMS report ─────────────────────────────────────── '6600' => 'Success', @@ -55,10 +58,11 @@ return [ // ── Alimtalk report ────────────────────────────────── '7000' => 'Success', + '7103' => 'Invalid sender profile key', '7106' => 'Deleted sender key', '7107' => 'Blocked sender key', - '7204' => 'Template mismatch', - '7206' => 'Unapproved template', + '7204' => 'Message content does not match template', + '7206' => 'Serial number format mismatch', '7306' => 'Kakao system error', '7307' => 'Processing delayed', '7308' => 'Phone number error', @@ -67,5 +71,5 @@ return [ '7421' => 'Timeout', '7436' => 'Insufficient wallet balance (Alimtalk)', '7437' => 'Message request failed', - '7523' => 'Other error', + '7523' => '080 opt-out (spam)', ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php index d6387fc5..63144999 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -7,8 +7,8 @@ declare(strict_types=1); | 비즈뿌리오 결과 코드 → 사유 (한국어) |-------------------------------------------------------------------------- | -| 출처: 비즈뿌리오 API 매뉴얼 "9. Status_Code" 표 -| 기준일: 2026-07-13 (계획서 부록 C-3 1차 범위) +| 출처: 비즈뿌리오 공식 응답 코드 문서 (https://bizppurio.github.io/response-codes/) +| 기준일: 2026-07-27 (최신 공식 문서 기준으로 정합화 — 구 매뉴얼 표기 정정) | 범위: 공통(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523) | RCS(8000대)·네이버톡톡(5000대 일부)은 범위 밖(후속 D15). | @@ -20,16 +20,18 @@ declare(strict_types=1); return [ // ── 공통 / 발송 응답 ────────────────────────────────── '1000' => '성공', - '2000' => '요청 파라미터 오류', - '3001' => '필수 파라미터 누락', + '2000' => '메시지가 유효하지 않음', + '3001' => '인증 정보 무효(Basic)', '3002' => '토큰 무효(만료·폐기)', - '3004' => '허용되지 않은 요청', - '3005' => '인증 정보 무효', - '3006' => '계정 정보 오류', - '3007' => '발신번호 미등록', - '3009' => '메시지 타입 오류', - '3011' => '일시적 처리 지연', - '3013' => '일시적 시스템 오류', + '3003' => 'IP 무효', + '3004' => '계정이 유효하지 않음', + '3005' => '인증 정보 무효(Bearer)', + '3006' => '계정이 존재하지 않음', + '3007' => '계정 암호 무효', + '3009' => '계정 중지 상태', + '3010' => '접속 허용 IP 불일치', + '3011' => '알 수 없는 오류(비즈뿌리오)', + '3013' => '완료 처리되지 않은 메시지', '5003' => '일시적 전송 오류', '5004' => '일시적 전송 오류', '5005' => '일시적 전송 오류', @@ -45,8 +47,9 @@ return [ '4410' => '잘못된 번호', '4414' => '결번·정지', '4420' => '기타 단말 오류', - '4430' => '스팸 수신 거부', - '4443' => '기타 오류', + '4430' => '스팸', + '4431' => '발송 제한 수신거부(스팸)', + '4443' => '스팸 차단', // ── LMS 리포트 ──────────────────────────────────────── '6600' => '성공', @@ -58,10 +61,11 @@ return [ // ── 알림톡 리포트 ───────────────────────────────────── '7000' => '성공', + '7103' => '발신 프로필 키 무효', '7106' => '삭제된 발신 키', '7107' => '차단된 발신 키', - '7204' => '템플릿 불일치', - '7206' => '검수되지 않은 템플릿', + '7204' => '메시지 내용이 템플릿과 불일치', + '7206' => '시리얼넘버 형식 불일치', '7306' => '카카오 시스템 오류', '7307' => '처리 지연', '7308' => '전화번호 오류', @@ -70,5 +74,5 @@ return [ '7421' => '타임아웃', '7436' => '지갑 잔액 부족(알림톡)', '7437' => '메시지 요청 실패', - '7523' => '기타 오류', + '7523' => '080 수신거부(스팸)', ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php index 190468bf..f854ce79 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php @@ -26,12 +26,13 @@ class ResultCodeResolver /** * 재시도(일시 오류) 코드 — 큐가 재시도해야 하는 코드. * - * 공통 일시오류(5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류 - * (7306 카카오 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 포함한다. + * 공통 일시오류(5002 요청 과다·5003/5004/5005 게이트웨이 오류·9000 알 수 없는 오류· + * 3011 비즈뿌리오 내부 오류·3013 미완료 메시지)에 더해, 알림톡 일시오류(7306 카카오 + * 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 포함한다. * 7305(성공 불확실)는 이미 발송됐을 수 있어 중복발송 위험이 있으므로 제외한다. * 출처: 비즈뿌리오 공식 응답코드(bizppurio.github.io/response-codes). */ - private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437']; + private const RETRYABLE_CODES = ['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437']; /** * 잔액 부족 코드 — 선불 잔액부족 9070 / 알림톡 지갑 잔액부족 7436 / diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php index 9cdcaa7b..4da170bf 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -53,16 +53,27 @@ class ResultCodeResolverTest extends PluginTestCase $this->assertStringContainsString('9071', $this->resolver->label('9071')); } + /** + * 최신 명세 정합화로 추가된 코드(IP 오류·수신거부·발신프로필 키 무효)에 사유가 정의돼 + * 코드만 노출되지 않는다. + */ + public function test_newly_added_codes_have_reason(): void + { + foreach (['3003', '3010', '4431', '7103'] as $code) { + $this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락"); + } + } + /** * 일시오류 코드는 Retry. * - * 공통(5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류(7306 카카오 시스템오류· - * 7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 재시도 대상이다. 7305(성공 불확실)는 - * 이미 발송됐을 수 있어 중복발송 위험이 있으므로 재시도 대상에서 제외한다. + * 공통(5002 요청 과다·5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류(7306 카카오 + * 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 재시도 대상이다. + * 7305(성공 불확실)는 이미 발송됐을 수 있어 중복발송 위험이 있으므로 재시도 대상에서 제외한다. */ public function test_retryable_codes_categorized_as_retry(): void { - foreach (['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) { + foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) { $this->assertSame(ResultCategory::Retry, $this->resolver->categorize($code), "코드 {$code}"); } } From f66f1a2f20e760e1dbc69b25f749b879f99e5c4f Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 15:06:08 +0900 Subject: [PATCH 30/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=B9=84=EC=A6=88?= =?UTF-8?q?=EB=BF=8C=EB=A6=AC=EC=98=A4=20=EC=9D=91=EB=8B=B5=EC=9D=98=20?= =?UTF-8?q?=EC=8B=A4=EC=A0=9C=20=EC=82=AC=EC=9C=A0=C2=B7=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=9D=B4=EB=A0=A5=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발송 API(/v3/message) 가 HTTP 오류로 실패하면 "메시지 발송 요청에 실패했습니다" 라는 고정 문구만 남고 비즈뿌리오가 준 실제 사유·코드를 버려, 발송 이력·로그로 원인을 알 수 없었다. 실패 응답 body 의 code·description 을 추출해 예외에 실어 발송 이력에 "발신 프로필 키가 유효하지 않음 (7103)"처럼 구체 사유가 남도록 한다. body 가 없으면 기존 기본 문구로 폴백한다(결과코드 null). --- .../sirsoft-message_bizppurio/CHANGELOG.md | 1 + .../src/Services/BizppurioApiClient.php | 12 ++++- .../Unit/Services/BizppurioApiClientTest.php | 44 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 28c1dfb6..2423abfa 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -38,6 +38,7 @@ - 플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. - 잔액 부족으로 발송이 실패해 관리자에게 보내는 알림에서, 실패한 채널(문자/알림톡)과 결과 코드가 안내 문구에 실제 값으로 표시됩니다. 후불 결제 한도를 초과한 경우도 잔액 부족과 동일하게 관리자에게 알립니다. - 발송 결과 코드의 사유 안내를 비즈뿌리오 최신 공식 명세에 맞춰 정비했습니다. 일부 코드의 설명을 정정하고 누락된 코드(접속 IP 오류, 수신거부, 발신 프로필 키 오류 등)를 추가해, 발송 이력에서 실패 사유가 더 정확히 표시됩니다. +- 발송 요청이 실패하면 비즈뿌리오가 알려준 실제 사유와 결과 코드가 발송 이력에 기록됩니다. 예전에는 "발송 요청 실패"로만 표시되어 원인을 알 수 없었지만, 이제 "발신 프로필 키가 유효하지 않음 (7103)"처럼 구체적인 사유가 표시되어 문제를 바로 확인할 수 있습니다. ### Changed diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php index d8c6a50b..d7ffd430 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php @@ -100,8 +100,18 @@ class BizppurioApiClient ->post($this->baseUrl().'/v3/message', $payload); if ($response->failed()) { + // 비즈뿌리오는 실패 응답에도 code·description 을 담아준다(명세). 이를 추출해 + // 예외에 실어, 발송 이력·로그에 "왜 실패했는지"(결과코드+사유)가 남도록 한다. + // body 가 없거나 파싱 불가하면 HTTP 상태만 보존한다. + $body = $response->json(); + $code = is_array($body) ? (string) ($body['code'] ?? '') : ''; + $description = is_array($body) ? (string) ($body['description'] ?? '') : ''; + throw new BizppurioApiException( - __('sirsoft-message_bizppurio::messages.error.send_failed'), + $description !== '' + ? $description + : __('sirsoft-message_bizppurio::messages.error.send_failed'), + resultCode: $code !== '' ? $code : null, httpStatus: $response->status(), ); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php index 84397004..e36ba1f8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php @@ -110,6 +110,50 @@ class BizppurioApiClientTest extends PluginTestCase $client->sendMessage($this->payload); } + /** + * HTTP 실패 응답에 code·description 이 있으면 예외에 사유·결과코드로 담긴다. + * + * 회귀 배경: 실패 시 "메시지 발송 요청에 실패했습니다"만 뜨고 비즈뿌리오가 준 실제 사유·코드를 + * 버려, 발송 이력·로그로 원인을 알 수 없던 문제. 실패 body 를 추출해 예외에 실어 이력에 남긴다. + */ + public function test_http_실패시_응답_body_의_사유와_코드가_예외에_담긴다(): void + { + Http::fake([ + '*/v3/message' => Http::response( + ['code' => 7103, 'description' => '발신 프로필 키가 유효하지 않음'], + 400, + ), + ]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + + try { + $client->sendMessage($this->payload); + $this->fail('예외가 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertSame('7103', $e->getResultCode(), '실패 body 의 code 가 예외에 담겨야 한다.'); + $this->assertSame('발신 프로필 키가 유효하지 않음', $e->getMessage(), '실패 body 의 description 이 예외 메시지로 담겨야 한다.'); + } + } + + /** + * HTTP 실패 응답에 body 가 없으면 기본 사유로 폴백하고 결과코드는 null. + */ + public function test_http_실패시_body_없으면_기본_사유_폴백(): void + { + Http::fake(['*/v3/message' => Http::response([], 500)]); + + $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings()); + + try { + $client->sendMessage($this->payload); + $this->fail('예외가 발생해야 한다.'); + } catch (BizppurioApiException $e) { + $this->assertNull($e->getResultCode()); + $this->assertNotSame('', $e->getMessage()); + } + } + public function test_영구실패_결과코드는_그대로_반환(): void { Http::fake([ From 49edacf5e06ed2d77b66fa67396411f739b226a1 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 27 Jul 2026 16:22:18 +0900 Subject: [PATCH 31/34] =?UTF-8?q?docs(message=5Fbizppurio):=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=EC=9E=90=EC=9A=A9=20README=20=EC=B6=94=EA=B0=80=20+?= =?UTF-8?q?=20CHANGELOG=20=EB=AF=B8=EB=B0=B0=ED=8F=AC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EC=A0=95=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 플러그인 README가 없어 webhook 등록, 검수/운영 계정, 알림톡 템플릿 역할 분리 등 운영자가 알아야 할 내용이 문서화되어 있지 않았다. 결제 플러그인(KG이니시스 등) README 관례를 따라 요구사항, 설치, 관리자 설정, webhook 등록, 발송 흐름, 결과 코드, 훅, API, 삭제 시 동작을 담은 README를 추가한다. CHANGELOG는 아직 공개 저장소가 없는 미배포 상태이므로, 개발 중 쌓인 이력성 문구("예전에는 ~였지만 이제 ~")를 걷어내고 1.0.0이 처음 제공하는 기능 목록만 남기도록 정리한다. ja 번들 언어팩 CHANGELOG도 동일 기준으로 정리한다. --- .../CHANGELOG.md | 8 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 42 +-- .../sirsoft-message_bizppurio/README.md | 243 ++++++++++++++++++ 3 files changed, 250 insertions(+), 43 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/README.md diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md index 2f015e2f..c48a6083 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -4,12 +4,8 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.0] - 2026-07-24 +## [1.0.0] - 2026-07-28 ### Added -- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공 -- 환경설정·알림톡 템플릿 관리·발송 이력 등 관리자 화면 텍스트와 발송 결과 코드 안내의 일본어 번역 추가 — 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다. -- 후불 한도 초과(9071) 결과 코드 사유의 일본어 번역 추가. -- 잔액부족 알림의 채널군 라벨(문자/알림톡) 일본어 번역 추가. -- 발송 결과 코드 사유를 최신 공식 명세에 맞춰 정합화(추가·정정)한 내용을 일본어 번역에 반영. +- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 2423abfa..4653b67c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -4,43 +4,11 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [1.0.0] - 2026-07-09 +## [1.0.0] - 2026-07-28 ### Added -- 비즈뿌리오 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다. -- 연동 환경을 운영으로 설정하면 발송에 필요한 아이디·비밀번호·발신번호가 반드시 입력되도록 검증하고, 실제 발송·비용이 발생한다는 경고를 함께 안내합니다. -- 발송 결과(리포트)를 받을 수신 주소를 설정 화면에서 확인·복사할 수 있습니다. 이 주소를 비즈뿌리오에 등록하면 발송 결과가 이력에 자동 기록됩니다. -- 비즈뿌리오 메시지 발송 기능에 대한 조회·관리 권한을 추가했습니다. 관련 화면은 플러그인 설정 페이지에서 진입합니다. -- 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다. -- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. 플러그인을 설치하면 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워져, 바로 켜고 편집할 수 있습니다. -- 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. -- 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) -- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). -- 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다. 대체발송을 실제로 요청한 건에서만 결과가 표시되어, 사용하지 않은 건에 잘못 표시되지 않습니다. 알림톡은 카카오 승인 템플릿의 실제 발송 내용을 별도로 함께 보여줘, 위에 표시되는 알림 기본 본문과 헷갈리지 않도록 합니다. -- 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. -- 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿의 목록·상태·내용을 확인할 수 있습니다. 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 하며, 이 화면은 목록·상태·내용 조회와 알림 연결을 담당합니다. 콘솔에서 등록한 템플릿도 이 목록에 그대로 표시되고 알림에 연결할 수 있습니다. -- 템플릿 목록을 상태(발송가능·검수중·반려·미검수·중지 등) 배지와 함께 보여주고, 상태·이름으로 필터·검색할 수 있습니다. 검색 결과가 없으면 오류 없이 "등록된 알림톡 템플릿이 없습니다"로 표시됩니다. 발송가능(승인) 상태의 템플릿만 알림에 연결해 발송할 수 있습니다. -- 각 템플릿의 [내용] 버튼으로 템플릿명·코드·카테고리·유형·본문·버튼을 담은 상세 내용을 확인할 수 있습니다. -- 목록 상단에 배지 의미(발송가능·검수중·미검수·반려)와 함께, 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 한다는 안내와 콘솔 바로가기를 제공합니다. -- 환경설정 탭에 카카오 알림톡·문자(SMS/LMS) 사용 전 준비 사항(비즈뿌리오 가입·API 승인·발신프로필 등록·발신번호 사전 등록)을 안내하는 박스를 추가했습니다. -- 템플릿 목록·상세 조회가 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 발신프로필 미설정 등)를 화면에 그대로 표시합니다. -- 알림에 연결한 카카오 알림톡 템플릿을 실제로 발송합니다. 카카오에 등록·승인된 템플릿의 본문뿐 아니라 버튼·바로연결·강조표기·아이템리스트·대표링크까지 그대로 발송에 반영하며, 각 항목의 변수는 발송 시점의 실제 데이터로 채워집니다. 버튼이나 링크가 포함된 템플릿도 정상적으로 발송됩니다. 버튼 URL이 로그인·비밀번호 재설정 등 완전한 주소 변수와 결합되는 경우에도 주소가 겹치지 않도록 처리합니다. -- 환경설정에 "알림톡 내용 캐시 시간(분)"과 "캐시 초기화" 버튼을 추가했습니다. 카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용하며(기본 60분, 0으로 두면 발송할 때마다 최신 내용을 가져옵니다), 카카오에서 템플릿을 방금 수정한 경우 [캐시 초기화]로 다음 발송부터 최신 내용이 즉시 반영되도록 할 수 있습니다. -- 알림톡 발송이 일시적인 오류(카카오 시스템 오류·처리 지연·타임아웃 등)로 실패한 경우 자동으로 다시 시도합니다. -- 알림에 연결한 알림톡 템플릿이 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요" 표시가 나타납니다. 예전에는 연결이 끊긴 뒤에도 정상 연결처럼 보여 발송 실패 후에야 알 수 있었지만, 이제 화면에서 바로 확인하고 다시 연결할 수 있습니다. (카카오 조회가 일시적으로 안 되는 경우에는 잘못된 경고가 뜨지 않도록 표시를 보류합니다.) -- 알림톡 연결 창에서 자격증명 오류 등으로 승인 템플릿 목록을 불러오지 못한 경우와, 승인된 템플릿이 실제로 없는 경우를 구분해 안내합니다. 불러오기에 실패하면 설정을 확인하라는 안내가, 실제로 없으면 템플릿을 먼저 등록·검수하라는 안내가 표시됩니다. -- 검수 모드 설정에 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장한다는 안내를 함께 표시합니다. -- 사용 전 준비 안내를 문자(SMS/LMS)와 카카오 알림톡 두 갈래로 나눠 넓은 화면에서 좌우 두 칸으로 보여주고, 각 항목에 채널 아이콘을 더해 한눈에 구분되도록 정리했습니다. -- 검수 모드로 발송된 문자·알림톡은 발송 이력 화면에 "발송중" 대신 "검수" 라벨로 표시되어, 실제 장애와 검수 도메인 특성을 운영자가 쉽게 구분할 수 있습니다. -- 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다. -- 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다. -- 플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. -- 잔액 부족으로 발송이 실패해 관리자에게 보내는 알림에서, 실패한 채널(문자/알림톡)과 결과 코드가 안내 문구에 실제 값으로 표시됩니다. 후불 결제 한도를 초과한 경우도 잔액 부족과 동일하게 관리자에게 알립니다. -- 발송 결과 코드의 사유 안내를 비즈뿌리오 최신 공식 명세에 맞춰 정비했습니다. 일부 코드의 설명을 정정하고 누락된 코드(접속 IP 오류, 수신거부, 발신 프로필 키 오류 등)를 추가해, 발송 이력에서 실패 사유가 더 정확히 표시됩니다. -- 발송 요청이 실패하면 비즈뿌리오가 알려준 실제 사유와 결과 코드가 발송 이력에 기록됩니다. 예전에는 "발송 요청 실패"로만 표시되어 원인을 알 수 없었지만, 이제 "발신 프로필 키가 유효하지 않음 (7103)"처럼 구체적인 사유가 표시되어 문제를 바로 확인할 수 있습니다. - -### Changed - -- 환경설정·발송 이력 화면의 "테스트 모드" 표기를 "검수 모드"로 통일했습니다. 실제 동작 변화는 없습니다. -- 알림 설정 화면에서 발송 설정이 미완료된 상태에서도 검수 모드 안내가 함께 표시되도록 했습니다. 두 안내 문구 사이 여백도 정리했습니다. +- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다. +- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. +- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. +- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/README.md b/plugins/_bundled/sirsoft-message_bizppurio/README.md new file mode 100644 index 00000000..d5ed4ac4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/README.md @@ -0,0 +1,243 @@ +# Bizppurio Messaging Plugin for G7 + +

+ Version + G7 + PHP + License +

+ +비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다. + +G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다. + +[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 연동](#알림톡-템플릿-연동) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트) + +--- + +## 주요 기능 + +- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택 +- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영 +- 알림톡 미승인/미연결 시 문자로 자동 대체발송(옵션) +- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용) +- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록 +- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증 +- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용) +- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시 +- 알림톡 템플릿 목록·상태·내용 조회 및 알림 연결 (템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행) +- 알림톡 템플릿이 카카오에서 삭제·차단·승인취소된 경우 "사용 불가 — 재연결 필요" 표시 + +--- + +## 요구 사항 + +| 구분 | 항목 | 내용 | +|------|------|------| +| 플랫폼 | G7 | `>= 7.0.3` | +| 플랫폼 | PHP | `^8.2` | +| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 | +| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) | +| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 + 발송할 템플릿의 카카오 검수 승인 | + +> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다. + +--- + +## 설치 + +플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다. + +```text +plugins/sirsoft-message_bizppurio +``` + +프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다. + +```bash +npm install +npm run build +``` + +그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워집니다. + +--- + +## 관리자 설정 + +관리자 플러그인 설정 화면에서 비즈뿌리오 계정 정보를 입력합니다. + +| 설정 | 필수 여부 | 설명 | +|------|:---:|------| +| 검수 모드 | - | 활성화 시 실제 발송 없이 검수용으로만 동작. 발송 이력에 "검수" 라벨로 표시되어 실제 장애와 구분됨 | +| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 필수 | 비즈뿌리오 계정 로그인 정보 | +| API 키 | 운영 시 필수 | 비즈뿌리오 API 인증에 사용 | +| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 | +| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 | +| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 | +| 알림톡 내용 캐시 시간(분) | 선택 (기본 60) | 카카오 템플릿 내용 재사용 시간. 0이면 매 발송마다 최신 조회, [캐시 초기화] 버튼으로 즉시 반영 가능 | + +> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다. + +--- + +## webhook(발송 결과 리포트) 등록 + +비즈뿌리오가 발송 결과를 통보할 URL을 비즈뿌리오 콘솔에 등록해야 발송 결과(성공/실패/사유)가 발송 이력에 자동 기록됩니다. + +> **이 등록을 하지 않으면 발송 자체는 되지만 성공/실패 여부를 확인할 수 없습니다.** + +```text +https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook +``` + +정확한 URL은 관리자 플러그인 설정 화면의 환경설정 탭에서도 복사할 수 있습니다. + +--- + +## 발송 흐름 + +```text +코어/모듈이 알림 발화 (예: 회원가입, 주문 완료) +→ 알림 설정에 연결된 문자·알림톡 채널로 발송 작업 큐잉 +→ 알림톡: 카카오 승인 템플릿 사용, 미승인/미연결 시 문자로 대체발송(옵션) +→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택 +→ 비즈뿌리오 발송 API 호출 +→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보 +→ 발송 이력에 결과 기록, 실패 시 사유·결과코드 함께 기록 +``` + +일시적 오류(카카오 시스템 오류, 처리 지연, 게이트웨이 오류 등)로 실패한 경우 자동으로 재시도합니다. 지갑 잔액 부족·후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리와 함께 관리자에게 알림이 발송됩니다. + +--- + +## 알림톡 템플릿 연동 + +카카오 알림톡 템플릿의 **등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서** 진행합니다. 이 플러그인의 설정 화면(알림톡 템플릿 탭)은 콘솔에 등록된 템플릿의 목록·상태·내용을 조회하고, 발송가능(승인) 상태의 템플릿을 알림에 연결하는 역할만 담당합니다. + +| 상태 | 발송 가능 | 설명 | +|:---:|:---:|------| +| 발송가능 | ✅ | 카카오 검수 승인 완료, 알림에 연결해 발송 가능 | +| 검수중 | ❌ | 카카오 검수 진행 중 | +| 반려 | ❌ | 카카오 검수 반려, 콘솔에서 재신청 필요 | +| 미검수 | ❌ | 콘솔에 등록만 되고 검수 신청 전 상태 | +| 중지 | ❌ | 사용 중지된 템플릿 | + +알림에 연결한 템플릿이 이후 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요"가 표시됩니다. 이 경우 알림톡 템플릿 탭에서 다른 승인 템플릿으로 다시 연결해야 합니다. + +--- + +## 발송 결과 코드 + +비즈뿌리오/카카오가 반환하는 결과 코드는 4가지로 분류되어 처리됩니다. + +| 분류 | 처리 방침 | +|:---:|------| +| 성공 | 발송 완료 | +| 재시도 (일시 오류) | 자동 재시도 대상 (예: 카카오 시스템 오류, 처리 지연, 게이트웨이 오류) | +| 잔액 부족 | 즉시 실패 처리 + 관리자 자체 알림 | +| 영구 실패 | 즉시 실패 처리, 재시도하지 않음 | + +주요 코드 예시: + +| 코드 | 분류 | 사유 | +|:---:|------|------| +| `1000` `4100` `6600` `7000` | 성공 | 발송/리포트 성공 | +| `9070` | 잔액 부족 | 잔액 부족(문자) | +| `9071` | 잔액 부족 | 후불 한도 초과 | +| `7436` | 잔액 부족 | 지갑 잔액 부족(알림톡) | +| `4400` | 영구 실패 | 음영 지역 | +| `7103` | 영구 실패 | 발신 프로필 키 무효 | + +발송 이력 화면에는 `사유 (코드)` 형식(예: "음영 지역 (4400)")으로 표시됩니다. 전체 코드 목록은 `lang/ko/result_codes.php` / `lang/en/result_codes.php`에 정의되어 있으며, lang에 없는 코드는 코드만 표시됩니다. + +--- + +## 가용 훅 (Hook) + +다른 모듈이나 플러그인에서 아래 훅에 연결해 잔액부족 상황을 확장 처리할 수 있습니다. + +### 액션 훅 + +| 훅 이름 | 시점 | 인수 | +|------|------|------| +| `sirsoft-message_bizppurio.balance.low` | 잔액 부족·한도 초과로 발송 실패 시 (쿨다운 내 최초 1회) | `string $resultCode, string $channel` | + +### 훅 등록 예시 + +```php +use App\Extension\HookManager; + +HookManager::addAction( + 'sirsoft-message_bizppurio.balance.low', + function (string $resultCode, string $channel) { + // 예: 잔액 부족 시 Slack으로도 별도 알림 + SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}"); + }, + priority: 10 +); +``` + +`$resultCode`는 잔액 부족(`9070` 문자 / `7436` 알림톡) 또는 후불 한도 초과(`9071`) 코드입니다. 이 훅은 관리자 자체 알림(잔액부족/후불한도초과 안내)을 발화하는 지점과 동일하며, 채널별 쿨다운(기본 3600초) 동안 한 번만 실행됩니다. + +--- + +## API + +전체 엔드포인트 레퍼런스는 [docs/api/README.md](docs/api/README.md)를 참고하세요. + +| 문서 | 내용 | +|------|------| +| [webhook.md](docs/api/webhook.md) | 비즈뿌리오 발송 결과 리포트 수신 | +| [templates.md](docs/api/templates.md) | 알림톡 템플릿 조회 | +| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 템플릿 관리자 API | +| [notification-bindings.md](docs/api/notification-bindings.md) | 알림-템플릿 연결 | +| [dispatch-results.md](docs/api/dispatch-results.md) | 발송 결과 조회 | +| [report.md](docs/api/report.md) | 발송 결과 리포트 URL 조회 | + +### 권한 + +| 권한 | 설명 | +|------|------| +| `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 | +| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 연결, 캐시 초기화 등 관리 작업 | + +--- + +## 삭제 시 동작 + +플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다. + +--- + +## 보안 및 운영 참고 + +- 비즈뿌리오 비밀번호, API 키, 알림톡 발신프로필 키는 외부에 노출하지 마세요. 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다. +- 운영 모드 전환 전 검수 모드에서 문자·알림톡 발송 흐름을 먼저 확인하세요. 운영 모드는 실제 발송과 비용이 발생합니다. +- webhook URL을 비즈뿌리오 콘솔에 등록하지 않으면 발송 결과 확인이 불가능합니다. +- 검수와 운영은 별도의 비즈뿌리오 계정 사용을 권장합니다. +- 지갑 잔액/후불 한도를 주기적으로 확인하세요. 부족 시 관리자 알림이 발송되지만, 알림 자체도 같은 채널(문자/알림톡)을 사용하지 않는 별도 채널(예: 사이트 내 알림, 메일)로 함께 받는 것을 권장합니다. + +--- + +## 테스트 + +플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다. + +```bash +php artisan test plugins/sirsoft-message_bizppurio/tests +``` + +프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다. + +```bash +npm install +npm run test:run +npm run build +``` + +--- + +## 라이선스 + +MIT \ No newline at end of file From 40d3ce9639b65bb73965c931b29bbdffe9ab84ce Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 28 Jul 2026 12:20:34 +0900 Subject: [PATCH 32/34] =?UTF-8?q?feat(sirsoft-board,sirsoft-ecommerce,mess?= =?UTF-8?q?age=5Fbizppurio):=20=EA=B2=8C=EC=8B=9C=ED=8C=90=C2=B7=EC=9D=B4?= =?UTF-8?q?=EC=BB=A4=EB=A8=B8=EC=8A=A4=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=ED=86=A1=20=EC=97=B0=EB=8F=99=20UI=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코어 알림 설정 화면에만 있던 비즈뿌리오 카카오 알림톡 템플릿 연결 UI(연결 버튼, 검수모드 배너, 안내박스)가 게시판·이커머스 알림 설정 화면에는 빠져 있었다. Phase 6 재설계 시 "후속 작업"으로 남겨두고 실제 반영이 되지 않았던 부분이다. - 게시판·이커머스 알림 정의 행에 코어와 동일한 notification_definition_row_footer 확장 슬롯을 추가하고, 게시판 레이아웃에는 코어/이커머스와 동일한 채널 콘텐츠 wrapper id를 신설해 구조를 맞췄다. - 비즈뿌리오 플러그인에 게시판·이커머스 전용 overlay 확장 파일을 추가해 배너· 안내박스·연결모달·데이터소스를 각 레이아웃에 주입한다. 백엔드는 notification_type을 확장 구분 없는 문자열 키로 이미 3영역 공용 처리하고 있어 프론트 진입점만 보완했다. - 회귀 테스트: 이커머스 화면에서 최초 구현 시 발견된 위치 버그(연결 버튼이 변수 뱃지 아래가 아니라 토글/편집 버튼 옆으로 붙던 문제)를 fail 재현 후 수정, 같은 회귀를 잡는 구조 검증 테스트를 게시판·이커머스 양쪽에 추가했다. --- modules/_bundled/sirsoft-board/CHANGELOG.md | 1 + ...tificationRowFooterExtensionPoint.test.tsx | 78 +++ .../_tab_notification_definitions.json | 214 ++++---- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 1 + ...tificationRowFooterExtensionPoint.test.tsx | 78 +++ .../_tab_notification_definitions.json | 9 + .../sirsoft-message_bizppurio/CHANGELOG.md | 2 +- .../extensions/notification_tab_board.json | 483 ++++++++++++++++++ .../notification_tab_ecommerce.json | 483 ++++++++++++++++++ .../notification_tab_board_ecommerce.test.tsx | 167 ++++++ 10 files changed, 1416 insertions(+), 100 deletions(-) create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 6bc3063b..3f7d1f9d 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -10,6 +10,7 @@ - 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. - 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다. +- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시). ### Changed diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx new file mode 100644 index 00000000..fcd56397 --- /dev/null +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx @@ -0,0 +1,78 @@ +// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는 +// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28) +/** + * 게시판 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28) + * + * @description + * 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면, + * 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼 + * (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다 + * (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림). + * + * extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야 + * 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json'; + +type AnyNode = Record & { + id?: string; + type?: string; + name?: string; + props?: { className?: string }; + children?: AnyNode[]; +}; + +function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] { + if (!node) return results; + if (pred(node)) results.push(node); + if (Array.isArray(node.children)) { + for (const c of node.children) findAll(c, pred, results); + } + return results; +} + +describe('게시판 알림 행 footer extension_point 위치 (#28)', () => { + it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => { + const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0'); + expect(infoColumns.length).toBeGreaterThan(0); + + const infoColumnHasEp = infoColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'), + ); + expect(infoColumnHasEp).toBe(true); + }); + + it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => { + const buttonColumns = findAll( + tab as AnyNode, + (n) => n.props?.className === 'flex-center gap-3 flex-shrink-0', + ); + expect(buttonColumns.length).toBeGreaterThan(0); + + const buttonColumnHasEp = buttonColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point'), + ); + expect(buttonColumnHasEp).toBe(false); + }); + + it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => { + const rowWrappers = findAll( + tab as AnyNode, + (n) => + Array.isArray(n.children) && + n.children.some((c) => c.props?.className === 'flex-1 min-w-0') && + n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'), + ); + expect(rowWrappers.length).toBeGreaterThan(0); + + for (const wrapper of rowWrappers) { + const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point'); + expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false); + } + }); +}); \ No newline at end of file diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json index 9f800b06..f15da32b 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json @@ -196,26 +196,16 @@ ] }, { - "id": "board_notif_list_info", + "id": "board_notif_channel_content", "type": "basic", "name": "Div", - "props": { - "className": "flex-between py-3" - }, "children": [ { - "type": "basic", - "name": "Span", - "props": { - "className": "text-label-subtle" - }, - "text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}" - }, - { + "id": "board_notif_list_info", "type": "basic", "name": "Div", "props": { - "className": "flex-center gap-2" + "className": "flex-between py-3" }, "children": [ { @@ -224,55 +214,70 @@ "props": { "className": "text-label-subtle" }, - "text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label" + "text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}" }, { - "type": "composite", - "name": "Select", + "type": "basic", + "name": "Div", "props": { - "name": "notifPerPage", - "className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm", - "value": "{{String(query.per_page || 20)}}", - "options": [ - { - "value": "10", - "label": "10" - }, - { - "value": "20", - "label": "20" - }, - { - "value": "50", - "label": "50" - } - ] + "className": "flex-center gap-2" }, - "actions": [ + "children": [ { - "type": "change", - "handler": "navigate", - "params": { - "path": "/admin/boards/settings", - "replace": true, - "mergeQuery": true, - "query": { - "per_page": "{{$event.target.value}}", - "page": 1 + "type": "basic", + "name": "Span", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label" + }, + { + "type": "composite", + "name": "Select", + "props": { + "name": "notifPerPage", + "className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm", + "value": "{{String(query.per_page || 20)}}", + "options": [ + { + "value": "10", + "label": "10" + }, + { + "value": "20", + "label": "20" + }, + { + "value": "50", + "label": "50" + } + ] + }, + "actions": [ + { + "type": "change", + "handler": "navigate", + "params": { + "path": "/admin/boards/settings", + "replace": true, + "mergeQuery": true, + "query": { + "per_page": "{{$event.target.value}}", + "page": 1 + } + } } - } + ] } ] } ] - } - ] - }, - { - "id": "board_notif_definitions_list", - "type": "basic", - "name": "Div", - "children": [ + }, + { + "id": "board_notif_definitions_list", + "type": "basic", + "name": "Div", + "children": [ { "type": "basic", "name": "Div", @@ -493,6 +498,15 @@ ] } ] + }, + { + "comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.", + "type": "extension_point", + "name": "notification_definition_row_footer", + "props": { + "definition": "{{def}}", + "activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}" + } } ] }, @@ -657,70 +671,72 @@ } ] }, - { - "id": "board_notif_empty", - "type": "basic", - "name": "Div", - "if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}", - "props": { - "className": "admin-card" - }, - "children": [ { + "id": "board_notif_empty", "type": "basic", "name": "Div", + "if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}", "props": { - "className": "row-stack" + "className": "admin-card" }, "children": [ { "type": "basic", - "name": "Icon", + "name": "Div", "props": { - "name": "fa-solid fa-envelope", - "className": "text-4xl text-gray-300 dark:text-gray-600 mb-3" - } - }, - { - "type": "basic", - "name": "P", - "props": { - "className": "text-label-subtle" + "className": "row-stack" }, - "text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message" + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fa-solid fa-envelope", + "className": "text-4xl text-gray-300 dark:text-gray-600 mb-3" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message" + } + ] } ] - } - ] - }, - { - "id": "board_notif_pagination", - "type": "basic", - "name": "Div", - "props": { - "className": "flex justify-center" - }, - "children": [ + }, { - "type": "composite", - "name": "Pagination", + "id": "board_notif_pagination", + "type": "basic", + "name": "Div", "props": { - "currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}", - "totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}" + "className": "flex justify-center" }, - "actions": [ + "children": [ { - "event": "onPageChange", - "type": "action", - "handler": "navigate", - "params": { - "path": "/admin/boards/settings", - "replace": true, - "mergeQuery": true, - "query": { - "page": "{{$args[0]}}" + "type": "composite", + "name": "Pagination", + "props": { + "currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}", + "totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}" + }, + "actions": [ + { + "event": "onPageChange", + "type": "action", + "handler": "navigate", + "params": { + "path": "/admin/boards/settings", + "replace": true, + "mergeQuery": true, + "query": { + "page": "{{$args[0]}}" + } + } } - } + ] } ] } diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 73fe64b1..2e0b7590 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -10,6 +10,7 @@ - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. - 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다. +- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시). ### Changed diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx new file mode 100644 index 00000000..caf7d366 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx @@ -0,0 +1,78 @@ +// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는 +// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28) +/** + * 이커머스 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28) + * + * @description + * 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면, + * 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼 + * (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다 + * (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림). + * + * extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야 + * 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다. + * + * @vitest-environment node + */ + +import { describe, it, expect } from 'vitest'; + +import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json'; + +type AnyNode = Record & { + id?: string; + type?: string; + name?: string; + props?: { className?: string }; + children?: AnyNode[]; +}; + +function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] { + if (!node) return results; + if (pred(node)) results.push(node); + if (Array.isArray(node.children)) { + for (const c of node.children) findAll(c, pred, results); + } + return results; +} + +describe('이커머스 알림 행 footer extension_point 위치 (#28)', () => { + it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => { + const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0'); + expect(infoColumns.length).toBeGreaterThan(0); + + const infoColumnHasEp = infoColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'), + ); + expect(infoColumnHasEp).toBe(true); + }); + + it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => { + const buttonColumns = findAll( + tab as AnyNode, + (n) => n.props?.className === 'flex-center gap-3 flex-shrink-0', + ); + expect(buttonColumns.length).toBeGreaterThan(0); + + const buttonColumnHasEp = buttonColumns.some((col) => + (col.children ?? []).some((c) => c.type === 'extension_point'), + ); + expect(buttonColumnHasEp).toBe(false); + }); + + it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => { + const rowWrappers = findAll( + tab as AnyNode, + (n) => + Array.isArray(n.children) && + n.children.some((c) => c.props?.className === 'flex-1 min-w-0') && + n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'), + ); + expect(rowWrappers.length).toBeGreaterThan(0); + + for (const wrapper of rowWrappers) { + const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point'); + expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false); + } + }); +}); \ No newline at end of file diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json index 20ee5d5e..d14ea83a 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json @@ -498,6 +498,15 @@ ] } ] + }, + { + "comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.", + "type": "extension_point", + "name": "notification_definition_row_footer", + "props": { + "definition": "{{def}}", + "activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}" + } } ] }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 4653b67c..f18b6e25 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -9,6 +9,6 @@ ### Added - 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다. -- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. +- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. - 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. - 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json new file mode 100644 index 00000000..a14f9948 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json @@ -0,0 +1,483 @@ +{ + "target_layout": "admin_board_settings", + "comment": "게시판 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 게시판 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 게시판 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).", + "data_sources": [ + { + "id": "bizppurioBindings", + "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "fallback": { + "data": { + "bindings": {} + } + } + }, + { + "id": "bizppurioApprovedTemplates", + "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "templates": [], + "load_failed": true + } + } + } + ], + "injections": [ + { + "target_id": "board_notif_channel_content", + "position": "prepend_child", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", + "components": [ + { + "id": "bizppurio_board_status_banner", + "type": "basic", + "name": "Div", + "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", + "props": { + "className": "mt-4 mb-3 space-y-2" + }, + "children": [ + { + "id": "bizppurio_board_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "text": "$t:sirsoft-message_bizppurio.banner.setup_action", + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ] + } + ] + }, + { + "id": "bizppurio_board_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } + ] + } + ] + }, + { + "id": "bizppurio_board_alimtalk_guide", + "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", + "type": "basic", + "name": "Div", + "if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}", + "props": { + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } + ] + } + ] + } + ], + "modals": [ + { + "id": "modal_bizppurio_binding", + "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "id": "bizppurio_board_binding_modal_body", + "type": "basic", + "name": "Div", + "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", + "props": { + "className": "space-y-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.connected_template" + }, + { + "type": "composite", + "name": "Select", + "props": { + "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "className": "w-full text-sm", + "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.template_code": "{{$event.target.value}}", + "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" + }, + { + "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Toggle", + "props": { + "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", + "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-secondary text-xs" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" + } + ] + }, + { + "id": "bizppurio_board_binding_fallback_hint", + "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" + }, + { + "id": "bizppurio_board_binding_variables", + "type": "basic", + "name": "Div", + "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap gap-1" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "iteration": { + "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_binding" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.save", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", + "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", + "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_binding" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] + } + ], + "init_actions": [ + { + "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json new file mode 100644 index 00000000..f2699a76 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json @@ -0,0 +1,483 @@ +{ + "target_layout": "admin_ecommerce_settings", + "comment": "이커머스 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 이커머스 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 이커머스 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).", + "data_sources": [ + { + "id": "bizppurioBindings", + "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "fallback": { + "data": { + "bindings": {} + } + } + }, + { + "id": "bizppurioApprovedTemplates", + "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "method": "GET", + "auto_fetch": false, + "auth_required": true, + "errorHandling": { + "default": { + "handler": "suppress" + } + }, + "fallback": { + "data": { + "templates": [], + "load_failed": true + } + } + } + ], + "injections": [ + { + "target_id": "ecommerce_notif_channel_content", + "position": "prepend_child", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.", + "components": [ + { + "id": "bizppurio_ecommerce_status_banner", + "type": "basic", + "name": "Div", + "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", + "props": { + "className": "mt-4 mb-3 space-y-2" + }, + "children": [ + { + "id": "bizppurio_ecommerce_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "text": "$t:sirsoft-message_bizppurio.banner.setup_action", + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ] + } + ] + }, + { + "id": "bizppurio_ecommerce_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } + ] + } + ] + }, + { + "id": "bizppurio_ecommerce_alimtalk_guide", + "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.", + "type": "basic", + "name": "Div", + "if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}", + "props": { + "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-blue-500 dark:text-blue-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-blue-800 dark:text-blue-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.list_guide" + } + ] + } + ] + } + ], + "modals": [ + { + "id": "modal_bizppurio_binding", + "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.", + "type": "composite", + "name": "Modal", + "props": { + "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}", + "size": "md", + "closeOnOverlayClick": false + }, + "children": [ + { + "id": "bizppurio_ecommerce_binding_modal_body", + "type": "basic", + "name": "Div", + "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}", + "props": { + "className": "space-y-4" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.connected_template" + }, + { + "type": "composite", + "name": "Select", + "props": { + "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "className": "w-full text-sm", + "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.template_code": "{{$event.target.value}}", + "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}", + "props": { + "className": "text-xs text-red-600 dark:text-red-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed" + }, + { + "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.", + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Toggle", + "props": { + "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}", + "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-secondary text-xs" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" + } + ] + }, + { + "id": "bizppurio_ecommerce_binding_fallback_hint", + "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.", + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint" + }, + { + "id": "bizppurio_ecommerce_binding_variables", + "type": "basic", + "name": "Div", + "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}", + "props": { + "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3" + }, + "children": [ + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-gray-600 dark:text-gray-400 mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex flex-wrap gap-1" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "iteration": { + "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}", + "item_var": "v" + }, + "props": { + "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300" + }, + "text": "{{'#{' + (v.key ?? '') + '}'}}" + } + ] + } + ] + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700" + }, + "children": [ + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.cancel", + "actions": [ + { + "type": "click", + "handler": "closeModal", + "target": "modal_bizppurio_binding" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50", + "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}" + }, + "text": "$t:common.save", + "actions": [ + { + "type": "click", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": true + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}", + "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}", + "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}", + "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + }, + { + "handler": "closeModal", + "target": "modal_bizppurio_binding" + }, + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + } + ], + "onError": [ + { + "handler": "setState", + "params": { + "target": "global", + "bizppurio_binding_modal.isSaving": false + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + } + ] + } + ] + } + ], + "init_actions": [ + { + "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).", + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx new file mode 100644 index 00000000..7e89914c --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx @@ -0,0 +1,167 @@ +// e2e:allow 게시판·이커머스 배너/버튼/모달 노출은 Chrome MCP 로 실브라우저 확인·수정까지 완료 +// (2026-07-28). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸(코어와 동일 사유). +/** + * 게시판·이커머스 알림 설정 알림톡 탭 연동 UI 구조 검증 (이슈 #28 후속) + * + * 배경: notification_tab_core.json(target_layout=admin_settings) + notification_row_footer.json + * (extension_point, 전역 매칭)은 코어 알림 설정 화면에만 연동 버튼·배너를 노출했다. + * extension_point 는 이름만 같으면 어느 레이아웃에서도 매칭되지만, target_id 기반 overlay + * injection 은 레이아웃별로 독립이라 게시판·이커머스는 배너/안내박스/연결모달이 뜨지 않았다. + * + * notification_tab_board.json / notification_tab_ecommerce.json 을 신설해 + * target_layout=admin_board_settings / admin_ecommerce_settings 로 각각 등록했다. + * notification_row_footer.json 은 그대로(전역 매칭)이므로 재사용된다. + */ + +import { describe, it, expect } from 'vitest'; +import boardOverlay from '../../../extensions/notification_tab_board.json'; +import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { findById, type AnyNode } from './helpers'; + +type OverlayFixture = { + label: string; + overlay: typeof boardOverlay; + targetLayout: string; + targetId: string; + bannerId: string; + notReadyId: string; + testModeId: string; + guideId: string; + modalBodyId: string; +}; + +const FIXTURES: OverlayFixture[] = [ + { + label: '게시판', + overlay: boardOverlay, + targetLayout: 'admin_board_settings', + targetId: 'board_notif_channel_content', + bannerId: 'bizppurio_board_status_banner', + notReadyId: 'bizppurio_board_banner_not_ready', + testModeId: 'bizppurio_board_banner_test_mode', + guideId: 'bizppurio_board_alimtalk_guide', + modalBodyId: 'bizppurio_board_binding_modal_body', + }, + { + label: '이커머스', + overlay: ecommerceOverlay, + targetLayout: 'admin_ecommerce_settings', + targetId: 'ecommerce_notif_channel_content', + bannerId: 'bizppurio_ecommerce_status_banner', + notReadyId: 'bizppurio_ecommerce_banner_not_ready', + testModeId: 'bizppurio_ecommerce_banner_test_mode', + guideId: 'bizppurio_ecommerce_alimtalk_guide', + modalBodyId: 'bizppurio_ecommerce_binding_modal_body', + }, +]; + +const collectPluginKeys = (json: unknown): string[] => { + const text = JSON.stringify(json); + const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; + return Array.from(new Set([ + ...prefixed.map((m) => m.replace('$t:', '')), + ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), + ])); +}; + +describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({ + overlay, targetLayout, targetId, bannerId, notReadyId, testModeId, guideId, modalBodyId, +}) => { + const injectionRoot = { + children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []) + .flatMap((i) => i.components ?? []), + } as AnyNode; + const modalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode; + + it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => { + expect((overlay as { target_layout?: string }).target_layout).toBe(targetLayout); + expect((overlay as Record).extension_point).toBeUndefined(); + }); + + it(`배너·안내박스는 target_id=${targetId} 에 prepend_child 로 주입된다`, () => { + const injections = (overlay as { injections?: Array> }).injections ?? []; + expect(injections).toHaveLength(1); + expect(injections[0].target_id).toBe(targetId); + expect(injections[0].position).toBe('prepend_child'); + }); + + it('연결 맵·승인 템플릿 데이터소스를 등록한다(코어와 동일 endpoint)', () => { + const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id); + expect(ids).toContain('bizppurioBindings'); + expect(ids).toContain('bizppurioApprovedTemplates'); + }); + + it('상태 배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => { + const banner = findById(injectionRoot, bannerId); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).toContain("'sms'"); + expect(cond).toContain("'alimtalk'"); + expect(cond).toContain('readiness?.ready === false'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { + const raw = JSON.stringify(findById(injectionRoot, notReadyId)); + expect(raw).toContain('banner.not_ready'); + expect(raw).toContain('banner.setup_action'); + expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); + }); + + it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다', () => { + const banner = findById(injectionRoot, testModeId); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).not.toContain('readiness'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('알림톡 탭 상시 안내 박스가 있다', () => { + const guide = findById(injectionRoot, guideId); + expect(guide).toBeTruthy(); + expect((guide as { if?: string }).if).toContain("=== 'alimtalk'"); + expect(JSON.stringify(guide)).toContain('binding.list_guide'); + }); + + it('연결 전용 모달(modal_bizppurio_binding)이 modals 로 등록된다', () => { + const modal = findById(modalRoot, 'modal_bizppurio_binding'); + expect(modal).toBeTruthy(); + expect((modal as { name?: string }).name).toBe('Modal'); + }); + + it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => { + const modal = findById(modalRoot, 'modal_bizppurio_binding'); + const raw = JSON.stringify(modal); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('binding.saved'); + expect(raw).toContain('"closeModal"'); + expect(raw).toContain('bizppurioBindings'); + }); + + it('연결 템플릿이 없으면 SMS 대체 토글이 비활성이다(코어와 동일 규칙)', () => { + const raw = JSON.stringify(findById(modalRoot, modalBodyId)); + expect(raw).toContain('"disabled"'); + expect(raw).toContain("=== ''"); + }); + + it('overlay 는 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => { + const raw = JSON.stringify(overlay); + expect(raw).not.toContain('/api/admin/notification-templates/'); + expect(raw).not.toContain('notification_template_form_modal'); + }); + + it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => { + const keys = collectPluginKeys(overlay); + expect(keys.length).toBeGreaterThan(0); + const resolve = (root: unknown, path: string): unknown => + path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); + for (const key of Array.from(new Set(keys))) { + expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); + expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); + } + }); +}); From 0a6a8d6968a36621e8f75169a483bb03bb674a0f Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 28 Jul 2026 15:48:45 +0900 Subject: [PATCH 33/34] =?UTF-8?q?fix(message=5Fbizppurio):=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=BD=94=EB=93=9C=205002=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=ED=8C=A9=20=EB=88=84=EB=9D=BD=20+=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=ED=86=A1=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=EA=B2=80=EC=A6=9D=20=EB=B6=80=EC=9E=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 준비 최종 검토 중 문제코드 처리 대조표를 재검증하며 발견한 결함 2건. - ResultCodeResolver 가 5002(요청 과다)를 재시도 대상으로 분류하지만 ko/en/ja 언어팩 어디에도 사유가 없어 코드값만 노출되던 결함 수정 - 알림톡 템플릿 연결 저장이 승인 상태(RDY/ACT) 재검증 없이 그대로 upsert되어, 화면 드롭다운(승인 템플릿만 노출)을 우회한 API 직접 호출 시 미승인 템플릿도 저장 가능했던 결함 수정 — bind 에 카카오 승인 목록 대조 추가, 조회 자체 실패 시에도 안전측으로 저장 거부 --- app/Notifications/GenericNotification.php | 32 ++------- .../CHANGELOG.md | 2 +- .../backend/ja/messages.php | 1 + .../backend/ja/result_codes.php | 1 + .../sirsoft-message_bizppurio/CHANGELOG.md | 4 +- .../docs/api/notification-bindings.md | 3 +- .../lang/en/messages.php | 1 + .../lang/en/result_codes.php | 1 + .../lang/ko/messages.php | 1 + .../lang/ko/result_codes.php | 1 + .../Admin/NotificationBindingController.php | 20 +++--- .../Services/NotificationBindingService.php | 33 ++++++++- .../NotificationBindingEndpointTest.php | 60 +++++++++++++++++ .../NotificationBindingServiceTest.php | 67 +++++++++++++++++-- .../Unit/Services/ResultCodeResolverTest.php | 13 ++++ .../GenericNotificationViaTest.php | 2 + 16 files changed, 195 insertions(+), 47 deletions(-) diff --git a/app/Notifications/GenericNotification.php b/app/Notifications/GenericNotification.php index d21be8f4..9e6ad309 100644 --- a/app/Notifications/GenericNotification.php +++ b/app/Notifications/GenericNotification.php @@ -67,11 +67,13 @@ class GenericNotification extends BaseNotification // 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인 if ($this->channel !== null) { try { + $channelService = app(NotificationChannelService::class); + // 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음). // 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만, // 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지 // 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님). - if (! $this->isChannelAvailable($this->channel)) { + if (! $channelService->isChannelAvailable($this->channel)) { return []; } @@ -85,7 +87,6 @@ class GenericNotification extends BaseNotification } // 0단계: 확장 단위 채널 전역 활성 여부 확인 - $channelService = app(NotificationChannelService::class); if (! $channelService->isChannelEnabledForExtension( $this->extensionType, $this->extensionIdentifier, @@ -136,7 +137,7 @@ class GenericNotification extends BaseNotification foreach ($channels as $channel) { // 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이 // 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책). - if (! $this->isChannelAvailable($channel)) { + if (! $channelService->isChannelAvailable($channel)) { continue; } @@ -208,31 +209,6 @@ class GenericNotification extends BaseNotification } } - /** - * 채널이 현재 사용 가능한(살아 있는) 채널 목록에 있는지 확인합니다. - * - * getAvailableChannels() 는 코어 기본 채널(mail/database)과 활성 확장이 filter 훅으로 추가한 - * 채널만 담는다. 확장이 비활성/삭제되면 그 채널이 이 목록에서 자동으로 빠지므로, 특정 확장 - * 이름을 알지 못해도 "죽은 채널"을 판별할 수 있다. 조회 실패 시(부팅 초기 등) 안전측으로 true - * 를 반환해 기존 게이트(확장 토글·readiness)가 판정하도록 위임한다. - * - * @param string $channel 채널 식별자 - * @return bool 사용 가능하면 true - */ - private function isChannelAvailable(string $channel): bool - { - try { - $availableIds = array_column( - app(NotificationChannelService::class)->getAvailableChannels(), - 'id' - ); - - return in_array($channel, $availableIds, true); - } catch (\Throwable $e) { - return true; - } - } - /** * 비회원 수신자에게 해당 채널 발송이 차단되는지 확인합니다. * diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md index c48a6083..57ad5f67 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md @@ -8,4 +8,4 @@ ### Added -- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다. diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php index ca0770ef..5650439c 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php @@ -57,6 +57,7 @@ return [ 'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。', 'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。', 'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。', + 'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)', ], 'send_skipped' => [ 'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)', diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php index e16ebad7..8309d4c5 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php +++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php @@ -24,6 +24,7 @@ return [ '4430' => 'スパム', '4431' => '送信制限受信拒否(スパム)', '4443' => 'スパムブロック', + '5002' => 'リクエストが多すぎます', '5003' => '一時的な送信エラー', '5004' => '一時的な送信エラー', '5005' => '一時的な送信エラー', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index f18b6e25..eb97c84f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -9,6 +9,6 @@ ### Added - 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다. -- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. -- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. +- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다. +- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다. - 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md index 903f5225..4ea771f9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -144,12 +144,13 @@ HTTP/1.1 200 | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | | 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | +| 422 | Unprocessable Entity | `template_code` 가 카카오 승인 상태(RDY/ACT)가 아니거나, 카카오 승인 목록 조회 자체가 실패(자격증명 미설정·장애)한 경우 (`error.kakao_message`/`error.result_code`) | **설명** -알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 승인되지 않은 템플릿 코드도 저장 자체는 가능하나(운영자 선택 자유), 편집 모달 드롭다운은 승인(RDY/ACT) 템플릿만 노출해 실수를 방지한다. +알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 저장 전 카카오 승인 상태(RDY/ACT)를 서버측에서 재검증하며, 미승인 코드거나 카카오 조회 자체가 실패하면 422 로 거부한다(편집 모달 드롭다운의 승인 템플릿 필터는 화면 단계일 뿐이라, 이를 우회한 직접 API 호출로 미승인 템플릿이 저장되는 것을 막기 위함). 연결 해제(`template_code` 빈 값)는 이 검증을 거치지 않는다. ### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index d6281577..31d107c1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -80,6 +80,7 @@ return [ 'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.', 'kakao_request_failed' => 'The Kakao management API request failed.', 'sender_key_missing' => 'Please set the alimtalk sender profile key first.', + 'template_not_sendable' => 'This template is not in a sendable (approved) state. (code: :code)', ], // Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php index 48b0e9f0..7ebca552 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -29,6 +29,7 @@ return [ '3010' => 'IP not in allowlist', '3011' => 'Unknown error (Bizppurio)', '3013' => 'Message not completed', + '5002' => 'Too many requests', '5003' => 'Temporary delivery error', '5004' => 'Temporary delivery error', '5005' => 'Temporary delivery error', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index df9b94eb..ebb6ae0a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -80,6 +80,7 @@ return [ 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.', 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.', 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.', + 'template_not_sendable' => '발송 가능(승인) 상태가 아닌 템플릿입니다. (코드: :code)', ], // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php index 63144999..cfd19c83 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -32,6 +32,7 @@ return [ '3010' => '접속 허용 IP 불일치', '3011' => '알 수 없는 오류(비즈뿌리오)', '3013' => '완료 처리되지 않은 메시지', + '5002' => '요청 과다', '5003' => '일시적 전송 오류', '5004' => '일시적 전송 오류', '5005' => '일시적 전송 오류', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php index 3294a69e..b74b9e47 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php @@ -83,15 +83,17 @@ class NotificationBindingController extends AdminBaseController { $validated = $request->validated(); - $this->service->applyFromTemplateSave( - $validated['notification_type'], - $validated['template_code'] ?? null, - $validated['template_name'] ?? null, - (bool) ($validated['fallback_sms_enabled'] ?? false), - ); + return $this->guard(function () use ($validated) { + $this->service->applyFromTemplateSave( + $validated['notification_type'], + $validated['template_code'] ?? null, + $validated['template_name'] ?? null, + (bool) ($validated['fallback_sms_enabled'] ?? false), + ); - return ResponseHelper::success('messages.binding.saved', [ - 'bindings' => $this->service->all(), - ]); + return ResponseHelper::success('messages.binding.saved', [ + 'bindings' => $this->service->all(), + ]); + }); } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php index 6bdf291f..f3fd3a19 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php @@ -130,20 +130,49 @@ class NotificationBindingService /** * 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장). * + * 저장 전 카카오 승인 상태(RDY/ACT)를 재검증한다 — 드롭다운이 승인 템플릿만 보여주지만 + * 그 필터는 화면 단계일 뿐이라, API 를 직접 호출하면 미승인 템플릿도 저장될 수 있었다(회귀). + * 카카오 조회 자체가 실패(장애·자격증명 미설정)하면 승인 여부를 판정할 수 없으므로 안전측으로 + * 저장을 거부한다 — 조회 실패를 "승인됨"으로 잘못 해석해 미승인 템플릿이 새는 것을 막는다. + * * @param string $notificationType 코어 notification_definitions.type * @param array $data template_code / template_name / fallback_sms_enabled * @return BizppurioNotificationBinding 저장된 연동 + * + * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 */ public function bind(string $notificationType, array $data): BizppurioNotificationBinding { + $templateCode = (string) $data['template_code']; + + $this->assertSendable($templateCode); + return $this->bindings->upsert($notificationType, self::CHANNEL, [ - 'template_code' => (string) $data['template_code'], + 'template_code' => $templateCode, 'template_name' => (string) $data['template_name'], 'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false), 'is_active' => true, ]); } + /** + * 템플릿 코드가 발송 가능(승인) 상태인지 검증합니다. 아니면 예외를 던집니다. + * + * @param string $templateCode 검증할 카카오 템플릿 코드 + * + * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 + */ + private function assertSendable(string $templateCode): void + { + $sendableCodes = array_column($this->approvedTemplates(), 'template_code'); + + if (! in_array($templateCode, $sendableCodes, true)) { + throw new BizppurioApiException( + __('sirsoft-message_bizppurio::messages.error.template_not_sendable', ['code' => $templateCode]), + ); + } + } + /** * 알림의 알림톡 연동을 해제(삭제)합니다. * @@ -184,6 +213,8 @@ class NotificationBindingService * @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제) * @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용) * @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부 + * + * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 (해제 시에는 미발생) */ public function applyFromTemplateSave( string $notificationType, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php index 2b58b5d4..106c2050 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php @@ -5,7 +5,10 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification; use App\Models\Permission; use App\Models\Role; use App\Models\User; +use Mockery; +use Mockery\MockInterface; use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding; +use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** @@ -20,6 +23,37 @@ class NotificationBindingEndpointTest extends PluginTestCase { private const BASE = '/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'; + /** + * AlimtalkTemplateService mock 을 컨테이너에 바인딩하고 반환한다. + * + * bind() 가 저장 전 카카오 승인 목록을 조회하므로(회귀 방지 재검증), 실제 kapi 호출 없이 + * 저장 경로를 검증하려면 이 mock 으로 승인 템플릿 목록을 지정해야 한다. + */ + private function mockTemplateService(): MockInterface + { + $mock = Mockery::mock(AlimtalkTemplateService::class); + $this->app->instance(AlimtalkTemplateService::class, $mock); + + return $mock; + } + + /** + * 지정 template_code 목록을 승인(ACT) 상태로 반환하는 mock 을 등록한다. + * + * @param array $codes 승인 처리할 template_code 목록 + */ + private function stubApprovedTemplates(array $codes): void + { + $templates = array_map( + fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'], + $codes, + ); + + $this->mockTemplateService() + ->shouldReceive('list') + ->andReturn(['templates' => $templates, 'pagination' => []]); + } + /** * 지정 권한 식별자들을 가진 admin 사용자를 만듭니다. * @@ -126,6 +160,7 @@ class NotificationBindingEndpointTest extends PluginTestCase public function test_manage_권한으로_연동을_즉시_저장한다(): void { + $this->stubApprovedTemplates(['TW_1236']); $admin = $this->adminWith([ 'sirsoft-message_bizppurio.messaging.view', 'sirsoft-message_bizppurio.messaging.manage', @@ -147,6 +182,29 @@ class NotificationBindingEndpointTest extends PluginTestCase ]); } + public function test_미승인_템플릿_코드로_저장하면_422이고_저장되지_않는다(): void + { + // 회귀: 화면 드롭다운은 승인 템플릿만 보여주지만, 그 필터를 우회해 API 를 직접 + // 호출하면 미승인 template_code 도 저장되던 결함. bind() 서버측 재검증으로 차단. + $this->stubApprovedTemplates(['TW_OTHER']); + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_UNAPPROVED', + 'template_name' => '미승인', + ]); + + $response->assertStatus(422); + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_UNAPPROVED', + ]); + } + public function test_빈_코드로_저장하면_연동을_해제한다(): void { BizppurioNotificationBinding::create([ @@ -163,6 +221,7 @@ class NotificationBindingEndpointTest extends PluginTestCase 'sirsoft-message_bizppurio.messaging.manage', ]); + // 해제는 카카오 승인 조회를 거치지 않으므로 mock 불필요. $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ 'notification_type' => 'welcome', 'template_code' => '', @@ -182,6 +241,7 @@ class NotificationBindingEndpointTest extends PluginTestCase 'sirsoft-message_bizppurio.messaging.manage', ]); + // FormRequest 검증(notification_type required)이 서비스 호출보다 먼저 실패하므로 mock 불필요. $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ 'template_code' => 'TW_1236', ]); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php index 5bbd6efd..b1f71f6b 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php @@ -162,9 +162,22 @@ class NotificationBindingServiceTest extends PluginTestCase $this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.'); } + /** + * bind() 승인 검증을 통과시키는 템플릿 목록으로 서비스를 만듭니다 (bind 계열 테스트 공용 fixture). + * + * @param array $approvedCodes 승인(발송 가능) 처리할 template_code 목록 + */ + private function makeServiceWithApprovedCodes(array $approvedCodes): NotificationBindingService + { + return $this->makeService(array_map( + fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'], + $approvedCodes, + )); + } + public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void { - $service = $this->makeService(); + $service = $this->makeServiceWithApprovedCodes(['TW_1236']); $service->bind('welcome', [ 'template_code' => 'TW_1236', @@ -189,7 +202,7 @@ class NotificationBindingServiceTest extends PluginTestCase public function test_bind는_같은_알림에_대해_갱신한다(): void { - $service = $this->makeService(); + $service = $this->makeServiceWithApprovedCodes(['TW_1', 'TW_2']); $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']); $service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']); @@ -201,9 +214,53 @@ class NotificationBindingServiceTest extends PluginTestCase ]); } + public function test_bind는_미승인_템플릿이면_예외를_던지고_저장하지_않는다(): void + { + // 회귀: bind() 가 승인 상태를 재검증하지 않아, 드롭다운(화면) 필터를 우회해 API 를 + // 직접 호출하면 미승인 템플릿도 저장되던 결함. + $service = $this->makeServiceWithApprovedCodes(['TW_OTHER']); + + $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class); + + try { + $service->bind('welcome', ['template_code' => 'TW_UNAPPROVED', 'template_name' => '미승인']); + } finally { + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_UNAPPROVED', + ]); + } + } + + public function test_bind는_카카오_조회_실패시_예외를_던지고_저장하지_않는다(): void + { + // 승인 여부를 판정할 수 없으면 안전측으로 저장을 거부한다(조회 실패="승인됨" 오인 방지). + $throwingTemplates = Mockery::mock(AlimtalkTemplateService::class); + $throwingTemplates->shouldReceive('list') + ->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정')); + + $service = new NotificationBindingService( + new BizppurioNotificationBindingRepository, + $throwingTemplates, + $this->fakeKakaoContent(), + ); + + $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class); + + try { + $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '가입환영']); + } finally { + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_1', + ]); + } + } + public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void { - $this->makeService()->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true); + $this->makeServiceWithApprovedCodes(['TW_9']) + ->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true); $this->assertDatabaseHas('bizppurio_notification_bindings', [ 'notification_type' => 'welcome', @@ -215,10 +272,10 @@ class NotificationBindingServiceTest extends PluginTestCase public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void { - $service = $this->makeService(); + $service = $this->makeServiceWithApprovedCodes(['TW_1']); $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']); - // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 + // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 (승인 검증 대상 아님) $service->applyFromTemplateSave('welcome', '', null, false); $this->assertDatabaseMissing('bizppurio_notification_bindings', [ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php index 4da170bf..35fb3c85 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -64,6 +64,19 @@ class ResultCodeResolverTest extends PluginTestCase } } + /** + * 재시도 대상으로 분류된 코드는 전부 사유가 lang 에 정의돼 있어야 한다(코드값만 노출 금지). + * + * 회귀: RETRYABLE_CODES 에 5002 가 분류돼 있었으나 lang/{ko,en}/result_codes.php 와 ja + * 번들에 5002 키가 누락되어 label('5002') 가 사유 없이 코드만 반환하던 결함. + */ + public function test_all_retryable_codes_have_reason(): void + { + foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) { + $this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락"); + } + } + /** * 일시오류 코드는 Retry. * diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php index d544af73..76663499 100644 --- a/tests/Unit/Notifications/GenericNotificationViaTest.php +++ b/tests/Unit/Notifications/GenericNotificationViaTest.php @@ -181,6 +181,8 @@ class GenericNotificationViaTest extends TestCase }); $mock->shouldReceive('getAvailableChannels') ->andReturn(array_map(static fn (string $id) => ['id' => $id], $availableIds)); + $mock->shouldReceive('isChannelAvailable') + ->andReturnUsing(fn (string $channel) => in_array($channel, $availableIds, true)); $this->app->instance(NotificationChannelService::class, $mock); } From 023ab4f099168bb5c587945ba4919e7dc1e4ac44 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 28 Jul 2026 16:14:44 +0900 Subject: [PATCH 34/34] =?UTF-8?q?chore(sirsoft-board,sirsoft-ecommerce,mes?= =?UTF-8?q?sage=5Fbizppurio):=20=EC=BD=94=EB=93=9C=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=82=B4=EB=B6=80=20=EC=9D=B4=EC=8A=88=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20+=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4?= =?UTF-8?q?=20=EB=A7=A4=EB=8B=88=ED=8E=98=EC=8A=A4=ED=8A=B8=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EA=B2=80=EC=A6=9D=20effect=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 준비 비판적 재검토 중 발견한 잔여 항목 2건 정리. - 게시판·이커머스 알림톡 연동 UI 테스트/레이아웃의 e2e:allow 주석·describe 블록에 남아있던 작업 중 이슈번호 제거 (audit 정규식은 형식만 검사해 이슈번호 잔존은 검출하지 못함) - alimtalk-dispatch-binding.yaml 시나리오 매니페스트에 NotificationBindingService 저장 시점 승인 재검증(assertSendable) effect 2건 추가 — 코드/테스트는 이전 커밋에 있었으나 매니페스트만 누락 --- .../admin-board-notification-channel-toggle.test.tsx | 8 ++++---- .../layouts/notificationRowFooterExtensionPoint.test.tsx | 6 +++--- .../_tab_notification_definitions.json | 2 +- .../adminEcommerceNotificationChannelToggle.test.tsx | 6 +++--- .../layouts/notificationRowFooterExtensionPoint.test.tsx | 6 +++--- .../tests/scenarios/alimtalk-dispatch-binding.yaml | 5 ++++- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx index 843dcfdf..af3ad751 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-channel-toggle.test.tsx @@ -1,6 +1,6 @@ -// e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 본 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재 (#28) +// e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 본 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재 /** - * 게시판 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 (#28) + * 게시판 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 * * @description * 회귀 시나리오 (코어 admin 템플릿과 동일 구조): @@ -21,7 +21,7 @@ import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notif const tabJson = JSON.stringify(tab); -describe('게시판 알림 채널 토글 저장 upsert (#28)', () => { +describe('게시판 알림 채널 토글 저장 upsert', () => { it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); @@ -36,7 +36,7 @@ describe('게시판 알림 채널 토글 저장 upsert (#28)', () => { }); }); -describe('게시판 하단 서브탭 필터 source 분기 (#28)', () => { +describe('게시판 하단 서브탭 필터 source 분기', () => { it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => { expect(/c\.source === 'core'/.test(tabJson)).toBe(true); expect(/is_active !== false/.test(tabJson)).toBe(true); diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx index fcd56397..457d2964 100644 --- a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx +++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx @@ -1,7 +1,7 @@ // e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는 -// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28) +// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 /** - * 게시판 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28) + * 게시판 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 * * @description * 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면, @@ -36,7 +36,7 @@ function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, resul return results; } -describe('게시판 알림 행 footer extension_point 위치 (#28)', () => { +describe('게시판 알림 행 footer extension_point 위치', () => { it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => { const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0'); expect(infoColumns.length).toBeGreaterThan(0); diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json index f15da32b..0a2bf959 100644 --- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json +++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json @@ -1,7 +1,7 @@ { "meta": { "is_partial": true, - "_e2e_allow": "e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재 (#28)", + "_e2e_allow": "e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재", "description": "게시판 알림 설정 탭 — 채널 토글 + 2단 탭 + 채널별 템플릿 목록" }, "id": "tab_content_notification_definitions", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx index 1965c9de..705c72b3 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceNotificationChannelToggle.test.tsx @@ -1,5 +1,5 @@ /** - * 이커머스 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 (#28) + * 이커머스 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드 * * @description * 회귀 시나리오 (코어 admin 템플릿과 동일 구조): @@ -20,7 +20,7 @@ import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_n const tabJson = JSON.stringify(tab); -describe('이커머스 알림 채널 토글 저장 upsert (#28)', () => { +describe('이커머스 알림 채널 토글 저장 upsert', () => { it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => { // 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가 expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true); @@ -35,7 +35,7 @@ describe('이커머스 알림 채널 토글 저장 upsert (#28)', () => { }); }); -describe('이커머스 하단 서브탭 필터 source 분기 (#28)', () => { +describe('이커머스 하단 서브탭 필터 source 분기', () => { it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => { expect(/c\.source === 'core'/.test(tabJson)).toBe(true); expect(/is_active !== false/.test(tabJson)).toBe(true); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx index caf7d366..53d85fec 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/notificationRowFooterExtensionPoint.test.tsx @@ -1,7 +1,7 @@ // e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는 -// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 (#28) +// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재 /** - * 이커머스 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 (#28) + * 이커머스 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드 * * @description * 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면, @@ -36,7 +36,7 @@ function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, resul return results; } -describe('이커머스 알림 행 footer extension_point 위치 (#28)', () => { +describe('이커머스 알림 행 footer extension_point 위치', () => { it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => { const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0'); expect(infoColumns.length).toBeGreaterThan(0); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml index 62585477..d4f549fd 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml @@ -7,7 +7,8 @@ # - 카카오→발송 변환(kakao_elements 축): AlimtalkPayloadMapperTest — 본문/버튼/바로연결/title·header·item·highlight·link 변환 + #{var} 치환 + 부재 필드 생략. # - 캐시 수동 초기화 엔드포인트·권한: AlimtalkTemplateControllerTest + NotificationBindingServiceTest. # - template 증강: SeedChannelTemplatesListenerTest — 회원판정/기본문구/채널추가/멱등. -# - 연동 즉시저장·조회·권한: NotificationBindingServiceTest + NotificationBindingEndpointTest. +# - 연동 즉시저장·조회·권한(저장 시점 카카오 승인 재검증 포함): NotificationBindingServiceTest + +# NotificationBindingEndpointTest. # - 채널 메타(is_test_mode): RegisterNotificationChannelsListenerTest. # - overlay 전용칸·배너·i18n·무오염: notification_tab_binding.test.tsx. # e2e_* 축(브라우저 왕복: 편집 열기→드롭다운 선택→즉시 저장→toast→배너)은 실제 카카오 발신 @@ -107,6 +108,8 @@ effects: - store_creates_or_updates_binding_immediately - store_empty_code_unbinds - only_approved_templates_offered_as_options + - bind_rejects_unapproved_template_code + - bind_rejects_when_kakao_lookup_fails - bindings_map_returned_for_prefill - view_permission_required_for_read - manage_permission_required_for_store