From a3495d8dc63d7f979c6d6ee8fb167c20554f215c Mon Sep 17 00:00:00 2001 From: chym1217 Date: Fri, 10 Jul 2026 11:40:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(message=5Fbizppurio):=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=C2=B7=EC=95=8C=EB=A6=BC=ED=86=A1=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20+=20=EC=9E=90=EB=8F=99=EB=B0=9C=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