feat(message_bizppurio): 문자·알림톡 채널 등록 + 자동발송

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 로 이관.
This commit is contained in:
chym1217 committed 2026-07-28 12:36:19 +09:00
1 parent 6549170bda
commit a3495d8dc6
13 files changed
+1241 -2

No files matched your search

@@ -11,3 +11,6 @@
- 비즈뿌리오(다우기술) 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다.
- 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다.
- 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다.
- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다.
- 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다.
- 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.)
@@ -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.',
@@ -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' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.',
@@ -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<class-string>
*/
public function getHookListeners(): array
{
return [];
return [
RegisterNotificationChannelsListener::class,
GuestPhoneExtractListener::class,
];
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
/**
* 비회원 주문 알림에 수신 전화번호를 실어주는 extract_data 필터 리스너 (D1).
*
* 코어 GuestNotifiable 과 이커머스 guest_recipient 표준 키는 email/name/locale 만 담고
* 전화번호가 없다. 따라서 비회원 SMS/알림톡을 보내려면 각 도메인의 extract_data 훅을
* 구독해 발송 채널 드라이버가 읽을 수 있는 표준 키(SmsChannelDriver::RECIPIENT_PHONE_KEY)로
* 전화번호를 data 에 채워야 한다. 이 리스너가 그 역할을 이커머스 주문 알림에 대해 수행한다.
*
* 의존 방향: 플러그인 → 이커머스(훅 구독 + 공개 관계 필드 읽기). 이커머스는 우리 전용 키를
* 모르고, 우리는 이커머스가 이미 GuestOrderAuthService·OrderResource 에서 하듯 배송지의
* orderer_phone 을 직접 읽는다(이커머스 모듈 무수정). 이커머스 미설치 시 이 훅은 발화하지
* 않으며, 설령 호출돼도 instanceof Order 게이트에서 무해하게 통과한다.
* 이커머스 자체 extract_data 리스너(기본 priority 10) 뒤에 실행되도록 priority 20 으로 둔다.
*/
class GuestPhoneExtractListener implements HookListenerInterface
{
/** 전화번호를 보강할 이커머스 주문 알림 유형 */
private const ORDER_NOTIFICATION_TYPES = [
'order_confirmed',
'order_pending_deposit',
'order_shipped',
'order_delivered',
'order_completed',
'order_cancelled',
];
/**
* 구독할 훅 목록 반환.
*
* @return array<string, array<string, mixed>>
*/
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<string, mixed> $result 이커머스 extract_data 결과
* @param string $type 알림 정의 유형
* @param array<int, mixed> $args 훅 원본 인수 ([$order, ...])
* @return array<string, mixed> 전화번호가 보강된(또는 원본) 결과
*/
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;
}
}
@@ -0,0 +1,258 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Services\PluginSettingsService;
/**
* 비즈뿌리오 문자·알림톡 채널을 코어 알림 시스템에 등록하는 필터 리스너.
*
* 코어를 수정하지 않고 다음 4계열 필터 훅을 구독해 sms·alimtalk 채널을 노출·게이트한다.
*
* 1) core.notification.filter_available_channels
* - 알림 설정 화면의 채널 탭 SSoT. sms·alimtalk 메타(name_key/allow_guest 등)를 추가한다.
* 2) core.notification.channel_readiness
* - 채널별 환경설정 충족 여부({ready, reason})를 반환한다(D2).
* 3) {prefix}.notification.channels (core.auth / sirsoft-ecommerce / sirsoft-board)
* - 레거시 다채널 자동 결정 경로에서 정의별 채널 후보에 sms·alimtalk 을 더한다(D7 3영역).
*
* 실제 발송 위임({prefix}.notification.to_sms/to_alimtalk)은 채널 드라이버가
* ChannelManager 에 등록되어야 발화하므로, 드라이버 등록은 ServiceProvider::boot() 가
* 담당한다(이 리스너는 채널 "노출·판정"만 책임진다).
*
* alimtalk 의 uses_custom_list 플래그(알림톡 탭 배타 전환)는 탭 UI 주입(§6-2)과 강결합이라
* Phase 6 에서 이 리스너에 추가한다. Phase 3 에서는 채널 메타·readiness·발송 위임 골격만 둔다.
*/
class RegisterNotificationChannelsListener implements HookListenerInterface
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** lang 네임스페이스 접두사 (plugin.php 와 동일) */
private const LANG = 'sirsoft-message_bizppurio::messages';
/** 이 플러그인이 등록하는 채널 ID 목록 */
private const CHANNEL_IDS = ['sms', 'alimtalk'];
/** 3영역 채널 후보 확장을 구독할 hookPrefix (D7) */
private const CHANNEL_HOOK_PREFIXES = ['core.auth', 'sirsoft-ecommerce', 'sirsoft-board'];
/**
* @param PluginSettingsService $pluginSettings readiness 검사를 위한 환경설정 조회
*/
public function __construct(
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 구독할 훅 목록 반환.
*
* filter_available_channels / channel_readiness 는 고정 훅이고,
* {prefix}.notification.channels 는 3영역 prefix 별로 동적으로 펼쳐 등록한다.
*
* @return array<string, array<string, mixed>>
*/
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<int, array<string, mixed>> $channels 현재 채널 메타 목록
* @return array<int, array<string, mixed>> 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<int, string> $channels 정의별 채널 id 후보
* @param string $type 알림 정의 유형 (미사용)
* @param object|null $notifiable 수신자 (미사용)
* @return array<int, string> 우리 채널이 더해진 후보
*/
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<int, array<string, mixed>>
*/
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];
}
}
@@ -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 {}
});
}
}
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Notifications\BaseNotification;
use App\Notifications\GenericNotification;
use App\Services\NotificationTemplateService;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
/**
* 코어 알림 시스템의 sms 채널 드라이버.
*
* ChannelManager::extend('sms', …)(ServiceProvider::boot)로 등록되어, 코어가
* `via()` 에서 'sms' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Laravel 채널 계약
* (`send($notifiable, Notification $notification)`)을 구현한다.
*
* 처리 흐름(계획서 Phase 3 ②):
* 1. sms 채널 알림 템플릿 resolve → 본문 렌더(변수 치환). 템플릿 없으면 skip(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 책임).
*
* 발송 이력(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<string, mixed> $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);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification;
use App\Extension\HookManager;
use App\Services\ChannelReadinessService;
use App\Services\NotificationChannelService;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 채널 등록 리스너를 실제 훅 체인으로 검증한다.
*
* 리스너 메서드를 직접 부르지 않고, HookManager 에 필터를 등록한 뒤 코어 서비스가
* 발화하는 훅을 통해 sms/alimtalk 채널이 노출되고 readiness 가 해석되는지 관찰한다.
* (Hook/Event 도메인 규정: 실제 훅 체인으로 관찰 가능한 상태 변화 검증)
*/
class ChannelRegistrationHookTest extends PluginTestCase
{
/**
* 리스너를 실제 훅 파이프라인에 등록합니다.
*
* @param array<string, string> $settings 플러그인 설정 stub
*/
private function registerListener(array $settings = []): void
{
$stub = new class($settings) extends PluginSettingsService
{
/** @param array<string, string> $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']);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Listeners;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* GuestPhoneExtractListener — 비회원 주문 알림 data 에 수신 전화번호 주입 검증(D1).
*/
class GuestPhoneExtractListenerTest extends PluginTestCase
{
private GuestPhoneExtractListener $listener;
protected function setUp(): void
{
parent::setUp();
$this->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']);
}
}
@@ -0,0 +1,190 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Listeners;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 채널 등록 리스너 단위 테스트.
*
* filter_available_channels(채널 메타), channel_readiness(설정 충족 판정),
* 3영역 .notification.channels(후보 확장) 각 필터 메서드의 동작을 검증한다.
*/
class RegisterNotificationChannelsListenerTest extends PluginTestCase
{
/**
* 설정값 맵으로 PluginSettingsService 를 대체한 리스너를 만든다.
*
* @param array<string, string> $settings plugin 설정 키→값
*/
private function makeListener(array $settings = []): RegisterNotificationChannelsListener
{
$stub = new class($settings) extends PluginSettingsService
{
/** @param array<string, string> $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']);
}
}
}
@@ -0,0 +1,182 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Models\NotificationTemplate;
use App\Models\User;
use App\Notifications\GenericNotification;
use App\Notifications\GuestNotifiable;
use App\Services\NotificationTemplateService;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Bus;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* SmsChannelDriver — 전화번호 해석·템플릿 게이트·SMS/LMS 판별·Job 위임 검증.
*
* 발송 Job dispatch 를 Bus::fake 로 관찰한다(실제 발송은 SendMessageJobTest 가 커버).
*/
class SmsChannelDriverTest extends PluginTestCase
{
/**
* 렌더 결과를 반환하는 NotificationTemplate mock 을 만듭니다.
*
* Eloquent 이벤트 재인스턴스화(new static)와 충돌하지 않도록 서브클래스 대신
* Mockery mock 을 사용한다(is_active + replaceVariables 만 stub).
*
* @param array<string, string> $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);
}
}
@@ -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