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:
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
+258
@@ -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];
|
||||
}
|
||||
}
|
||||
+56
@@ -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);
|
||||
}
|
||||
}
|
||||
+97
@@ -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']);
|
||||
}
|
||||
}
|
||||
+95
@@ -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']);
|
||||
}
|
||||
}
|
||||
+190
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
@@ -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
|
||||
Reference in new issue
Block a user