v7.0.0-beta.2 release
This commit is contained in:
1 parent
6595fd0eb5
commit
fa8eba7a0d
1165 files changed
+107118
-762127
No files matched your search
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Auth;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Mail\DbTemplateMail;
|
||||
use App\Notifications\BaseNotification;
|
||||
use App\Services\MailTemplateService;
|
||||
|
||||
/**
|
||||
* 비밀번호 변경 완료 알림
|
||||
*
|
||||
* 비밀번호가 변경되었음을 사용자에게 알립니다.
|
||||
* 훅을 통해 SMS, 카카오톡 등 다른 채널로 확장 가능합니다.
|
||||
*/
|
||||
class PasswordChangedNotification extends BaseNotification
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getHookPrefix(): string
|
||||
{
|
||||
return 'core.auth';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getNotificationType(): string
|
||||
{
|
||||
return 'password_changed';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 알림을 생성합니다.
|
||||
*
|
||||
* @param object $notifiable 수신자
|
||||
* @return DbTemplateMail DB 템플릿 기반 이메일 (비활성 시 스킵 인스턴스)
|
||||
*/
|
||||
public function toMail(object $notifiable): DbTemplateMail
|
||||
{
|
||||
$service = app(MailTemplateService::class);
|
||||
$template = $service->resolveTemplate('password_changed');
|
||||
|
||||
if (! $template) {
|
||||
return DbTemplateMail::skipped(
|
||||
recipientEmail: $notifiable->email,
|
||||
templateType: 'password_changed',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
$variables = [
|
||||
'name' => $notifiable->name ?? '',
|
||||
'app_name' => config('app.name'),
|
||||
'action_url' => config('app.url') . '/login',
|
||||
'site_url' => config('app.url'),
|
||||
];
|
||||
|
||||
$rendered = $service->renderTemplate($template, $variables);
|
||||
|
||||
return new DbTemplateMail(
|
||||
renderedSubject: $rendered['subject'],
|
||||
renderedBody: $rendered['body'],
|
||||
recipientEmail: $notifiable->email,
|
||||
templateType: 'password_changed',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Auth;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Mail\DbTemplateMail;
|
||||
use App\Notifications\BaseNotification;
|
||||
use App\Services\MailTemplateService;
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 알림
|
||||
*
|
||||
* 비밀번호 재설정 링크를 사용자에게 발송합니다.
|
||||
* 훅을 통해 SMS, 카카오톡 등 다른 채널로 확장 가능합니다.
|
||||
*/
|
||||
class ResetPasswordNotification extends BaseNotification
|
||||
{
|
||||
/**
|
||||
* 비밀번호 재설정 알림을 생성합니다.
|
||||
*
|
||||
* @param string $token 비밀번호 재설정 토큰
|
||||
* @param string|null $redirectPrefix 리다이렉트 경로 접두사 (예: 'admin')
|
||||
*/
|
||||
public function __construct(
|
||||
private string $token,
|
||||
private ?string $redirectPrefix = null
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getHookPrefix(): string
|
||||
{
|
||||
return 'core.auth';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getNotificationType(): string
|
||||
{
|
||||
return 'reset_password';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 알림을 생성합니다.
|
||||
*
|
||||
* @param object $notifiable 수신자
|
||||
* @return DbTemplateMail DB 템플릿 기반 이메일 (비활성 시 스킵 인스턴스)
|
||||
*/
|
||||
public function toMail(object $notifiable): DbTemplateMail
|
||||
{
|
||||
$service = app(MailTemplateService::class);
|
||||
$template = $service->resolveTemplate('reset_password');
|
||||
|
||||
if (! $template) {
|
||||
return DbTemplateMail::skipped(
|
||||
recipientEmail: $notifiable->email,
|
||||
templateType: 'reset_password',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
$resetPath = $this->redirectPrefix
|
||||
? '/' . $this->redirectPrefix . '/reset-password'
|
||||
: '/reset-password';
|
||||
$url = config('app.url') . $resetPath . '?' . http_build_query([
|
||||
'token' => $this->token,
|
||||
'email' => $notifiable->email,
|
||||
]);
|
||||
$expireMinutes = config('auth.passwords.users.expire', 60);
|
||||
|
||||
$variables = [
|
||||
'name' => $notifiable->name ?? '',
|
||||
'app_name' => config('app.name'),
|
||||
'action_url' => $url,
|
||||
'expire_minutes' => (string) $expireMinutes,
|
||||
'site_url' => config('app.url'),
|
||||
];
|
||||
|
||||
$rendered = $service->renderTemplate($template, $variables);
|
||||
|
||||
return new DbTemplateMail(
|
||||
renderedSubject: $rendered['subject'],
|
||||
renderedBody: $rendered['body'],
|
||||
recipientEmail: $notifiable->email,
|
||||
templateType: 'reset_password',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 재설정 토큰을 반환합니다.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getToken(): string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Auth;
|
||||
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Mail\DbTemplateMail;
|
||||
use App\Notifications\BaseNotification;
|
||||
use App\Services\MailTemplateService;
|
||||
|
||||
/**
|
||||
* 회원가입 완료 알림
|
||||
*
|
||||
* 회원가입이 완료되었음을 사용자에게 알립니다.
|
||||
* 훅을 통해 SMS, 카카오톡 등 다른 채널로 확장 가능합니다.
|
||||
*/
|
||||
class WelcomeNotification extends BaseNotification
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getHookPrefix(): string
|
||||
{
|
||||
return 'core.auth';
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
protected function getNotificationType(): string
|
||||
{
|
||||
return 'welcome';
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일 알림을 생성합니다.
|
||||
*
|
||||
* @param object $notifiable 수신자
|
||||
* @return DbTemplateMail DB 템플릿 기반 이메일 (비활성 시 스킵 인스턴스)
|
||||
*/
|
||||
public function toMail(object $notifiable): DbTemplateMail
|
||||
{
|
||||
$service = app(MailTemplateService::class);
|
||||
$template = $service->resolveTemplate('welcome');
|
||||
|
||||
if (! $template) {
|
||||
return DbTemplateMail::skipped(
|
||||
recipientEmail: $notifiable->email,
|
||||
templateType: 'welcome',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
$variables = [
|
||||
'name' => $notifiable->name ?? '',
|
||||
'app_name' => config('app.name'),
|
||||
'action_url' => config('app.url') . '/login',
|
||||
'site_url' => config('app.url'),
|
||||
];
|
||||
|
||||
$rendered = $service->renderTemplate($template, $variables);
|
||||
|
||||
return new DbTemplateMail(
|
||||
renderedSubject: $rendered['subject'],
|
||||
renderedBody: $rendered['body'],
|
||||
recipientEmail: $notifiable->email,
|
||||
templateType: 'welcome',
|
||||
extensionType: ExtensionOwnerType::Core,
|
||||
extensionIdentifier: 'core',
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\HookManager;
|
||||
use App\Mail\DbTemplateMail;
|
||||
use App\Services\NotificationChannelService;
|
||||
use App\Services\NotificationDefinitionService;
|
||||
use App\Services\NotificationLogService;
|
||||
use App\Services\NotificationTemplateService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class GenericNotification extends BaseNotification
|
||||
{
|
||||
/**
|
||||
* @param string $type 알림 타입 (welcome, order_confirmed 등)
|
||||
* @param string $hookPrefix 훅 접두사 (core.auth, sirsoft-ecommerce 등)
|
||||
* @param array $data 변수 데이터 (['name' => '홍길동', ...])
|
||||
* @param string $extensionType 확장 타입 (core, module, plugin)
|
||||
* @param string $extensionIdentifier 확장 식별자 (core, sirsoft-board 등)
|
||||
*/
|
||||
/**
|
||||
* @param string $type 알림 타입 (welcome, order_confirmed 등)
|
||||
* @param string $hookPrefix 훅 접두사 (core.auth, sirsoft-ecommerce 등)
|
||||
* @param array $data 변수 데이터 (['name' => '홍길동', ...])
|
||||
* @param string $extensionType 확장 타입 (core, module, plugin)
|
||||
* @param string $extensionIdentifier 확장 식별자 (core, sirsoft-board 등)
|
||||
* @param string|null $channel 대상 채널 (지정 시 해당 채널만 발송, null이면 기존 다채널 로직)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly string $type,
|
||||
private readonly string $hookPrefix,
|
||||
private readonly array $data = [],
|
||||
private readonly string $extensionType = 'core',
|
||||
private readonly string $extensionIdentifier = 'core',
|
||||
private readonly ?string $channel = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 훅 접두사 반환.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getHookPrefix(): string
|
||||
{
|
||||
return $this->hookPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 유형 반환.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getNotificationType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 채널 결정 — notification_definitions.channels 기반 + readiness 필터.
|
||||
*
|
||||
* 1. definition.channels 조회
|
||||
* 2. 훅 필터 적용 (플러그인 채널 추가/제거)
|
||||
* 3. readiness 필터 (미설정 채널 제외 + skipped 로깅)
|
||||
*
|
||||
* @param object $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
// 대상 채널이 지정된 경우 (template별 발송) — 확장 토글 + readiness 확인
|
||||
if ($this->channel !== null) {
|
||||
try {
|
||||
// 0단계: 확장 단위 채널 전역 활성 여부 확인
|
||||
$channelService = app(NotificationChannelService::class);
|
||||
if (! $channelService->isChannelEnabledForExtension(
|
||||
$this->extensionType,
|
||||
$this->extensionIdentifier,
|
||||
$this->channel
|
||||
)) {
|
||||
$this->logSkippedChannels([
|
||||
['channel' => $this->channel, 'reason' => 'notification.channel_disabled_by_extension'],
|
||||
], $notifiable);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$readinessChecker = app(ChannelReadinessCheckerInterface::class);
|
||||
if (! $readinessChecker->isReady($this->channel)) {
|
||||
$this->logSkippedChannels([
|
||||
['channel' => $this->channel, 'reason' => $readinessChecker->check($this->channel)['reason']],
|
||||
], $notifiable);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return [$this->channel];
|
||||
} catch (\Throwable $e) {
|
||||
return [$this->channel];
|
||||
}
|
||||
}
|
||||
|
||||
// 레거시: 다채널 자동 결정 (게시판 등 직접 발송 호환)
|
||||
$definitionService = app(NotificationDefinitionService::class);
|
||||
$definition = $definitionService->resolve($this->type);
|
||||
$channels = $definition?->channels ?? ['mail'];
|
||||
|
||||
$channels = HookManager::applyFilters(
|
||||
"{$this->hookPrefix}.notification.channels",
|
||||
$channels,
|
||||
$this->type,
|
||||
$notifiable
|
||||
);
|
||||
|
||||
// 확장 토글 + readiness + 템플릿 존재 필터
|
||||
try {
|
||||
$channelService = app(NotificationChannelService::class);
|
||||
$readinessChecker = app(ChannelReadinessCheckerInterface::class);
|
||||
$templateService = app(NotificationTemplateService::class);
|
||||
$readyChannels = [];
|
||||
$skippedChannels = [];
|
||||
|
||||
foreach ($channels as $channel) {
|
||||
// 0단계: 확장 단위 채널 전역 활성 여부 확인
|
||||
if (! $channelService->isChannelEnabledForExtension(
|
||||
$this->extensionType,
|
||||
$this->extensionIdentifier,
|
||||
$channel
|
||||
)) {
|
||||
$skippedChannels[] = [
|
||||
'channel' => $channel,
|
||||
'reason' => 'notification.channel_disabled_by_extension',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1단계: 채널 readiness 검사 (설정 완료 여부)
|
||||
if (! $readinessChecker->isReady($channel)) {
|
||||
$skippedChannels[] = [
|
||||
'channel' => $channel,
|
||||
'reason' => $readinessChecker->check($channel)['reason'],
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2단계: 활성 템플릿 존재 여부 검사
|
||||
// 템플릿이 없으면 채널 제외 — 빈 subject/body로 DB에 저장되는 것을 방지
|
||||
$template = $templateService->resolve($this->type, $channel);
|
||||
if (! $template) {
|
||||
$skippedChannels[] = [
|
||||
'channel' => $channel,
|
||||
'reason' => __('notification.channel_skipped_no_template', [
|
||||
'channel' => $channel,
|
||||
'type' => $this->type,
|
||||
]),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$readyChannels[] = $channel;
|
||||
}
|
||||
|
||||
if (! empty($skippedChannels)) {
|
||||
$this->logSkippedChannels($skippedChannels, $notifiable);
|
||||
}
|
||||
|
||||
return $readyChannels;
|
||||
} catch (\Throwable $e) {
|
||||
// readiness 서비스 실패 시 기존 동작 유지 (모든 채널 발송 시도)
|
||||
Log::warning('ChannelReadiness 서비스 실패, 기존 채널 전체 발송', [
|
||||
'type' => $this->type,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return $channels;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미설정 채널 건너뛰기를 notification_logs에 기록합니다.
|
||||
*
|
||||
* @param array $skippedChannels [{channel, reason}]
|
||||
* @param object $notifiable
|
||||
* @return void
|
||||
*/
|
||||
private function logSkippedChannels(array $skippedChannels, object $notifiable): void
|
||||
{
|
||||
try {
|
||||
$logService = app(NotificationLogService::class);
|
||||
foreach ($skippedChannels as $skipped) {
|
||||
$logService->logSkipped([
|
||||
'channel' => $skipped['channel'],
|
||||
'notification_type' => $this->type,
|
||||
'extension_type' => $this->extensionType,
|
||||
'extension_identifier' => $this->extensionIdentifier,
|
||||
'recipient_identifier' => $notifiable->email ?? (string) ($notifiable->getKey() ?? ''),
|
||||
'recipient_name' => $notifiable->name ?? null,
|
||||
'recipient_user_id' => $notifiable->getKey() ?? null,
|
||||
'error_message' => __($skipped['reason'] ?? 'notification.readiness.unknown'),
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Skipped 채널 로깅 실패', [
|
||||
'channels' => array_column($skippedChannels, 'channel'),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 채널 처리 — notification_templates에서 mail 채널 템플릿 조회.
|
||||
*
|
||||
* @param object $notifiable
|
||||
* @return DbTemplateMail
|
||||
*/
|
||||
public function toMail(object $notifiable): DbTemplateMail
|
||||
{
|
||||
$templateService = app(NotificationTemplateService::class);
|
||||
$template = $templateService->resolve($this->type, 'mail');
|
||||
|
||||
$ownerType = ExtensionOwnerType::tryFrom($this->extensionType) ?? ExtensionOwnerType::Core;
|
||||
|
||||
if (! $template || ! $template->is_active) {
|
||||
return DbTemplateMail::skipped(
|
||||
recipientEmail: $notifiable->email ?? '',
|
||||
templateType: $this->type,
|
||||
extensionType: $ownerType,
|
||||
extensionIdentifier: $this->extensionIdentifier,
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
$locale = $notifiable->locale ?? app()->getLocale();
|
||||
$rendered = $template->replaceVariables($this->data, $locale);
|
||||
|
||||
return new DbTemplateMail(
|
||||
renderedSubject: $rendered['subject'],
|
||||
renderedBody: $rendered['body'],
|
||||
recipientEmail: $notifiable->email ?? '',
|
||||
templateType: $this->type,
|
||||
extensionType: $ownerType,
|
||||
extensionIdentifier: $this->extensionIdentifier,
|
||||
source: 'notification',
|
||||
recipientName: $notifiable->name ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터베이스 채널 처리 — notification_templates에서 database 채널 템플릿 조회.
|
||||
*
|
||||
* @param object $notifiable
|
||||
* @return array
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$templateService = app(NotificationTemplateService::class);
|
||||
$template = $templateService->resolve($this->type, 'database');
|
||||
|
||||
$locale = $notifiable->locale ?? app()->getLocale();
|
||||
|
||||
if ($template && $template->is_active) {
|
||||
$rendered = $template->replaceVariables($this->data, $locale);
|
||||
|
||||
// click_url 패턴이 있으면 변수 치환하여 포함 (알림센터 클릭 시 이동 URL)
|
||||
$clickUrl = $template->click_url
|
||||
? $template->replaceVariablesInString($template->click_url, $this->data)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'subject' => $rendered['subject'],
|
||||
'body' => $rendered['body'],
|
||||
'click_url' => $clickUrl,
|
||||
'data' => $this->data,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $this->type,
|
||||
'data' => $this->data,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 미래 채널 자동 위임 (fcm 등).
|
||||
*
|
||||
* @param string $method
|
||||
* @param array $parameters
|
||||
* @return mixed
|
||||
*/
|
||||
public function __call($method, $parameters)
|
||||
{
|
||||
if (str_starts_with($method, 'to')) {
|
||||
$channel = lcfirst(substr($method, 2));
|
||||
|
||||
return HookManager::applyFilters(
|
||||
"{$this->hookPrefix}.notification.to_{$channel}",
|
||||
$this->data,
|
||||
$this->type,
|
||||
$parameters[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
throw new \BadMethodCallException("Method [{$method}] does not exist on " . static::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 데이터 반환.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 타입 반환.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string
|
||||
{
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 타입 반환.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getExtensionType(): string
|
||||
{
|
||||
return $this->extensionType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 식별자 반환.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getExtensionIdentifier(): string
|
||||
{
|
||||
return $this->extensionIdentifier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Contracts\Bus\Dispatcher as Bus;
|
||||
use Illuminate\Contracts\Events\Dispatcher;
|
||||
use Illuminate\Notifications\ChannelManager;
|
||||
|
||||
/**
|
||||
* G7 알림 ChannelManager
|
||||
*
|
||||
* NotificationDispatcher를 사용하여 모든 채널 발송에
|
||||
* G7 훅 파이프라인(before/after/failed)과 채널 독립 발송을 적용합니다.
|
||||
*/
|
||||
class NotificationChannelManager extends ChannelManager
|
||||
{
|
||||
/**
|
||||
* 알림을 발송합니다 (큐 지원).
|
||||
*
|
||||
* @param mixed $notifiables
|
||||
* @param mixed $notification
|
||||
* @return void
|
||||
*/
|
||||
public function send($notifiables, $notification)
|
||||
{
|
||||
return (new NotificationDispatcher(
|
||||
$this,
|
||||
$this->container->make(Bus::class),
|
||||
$this->container->make(Dispatcher::class),
|
||||
$this->locale
|
||||
))->send($notifiables, $notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림을 즉시 발송합니다 (큐 미사용).
|
||||
*
|
||||
* @param mixed $notifiables
|
||||
* @param mixed $notification
|
||||
* @param array|null $channels
|
||||
* @return void
|
||||
*/
|
||||
public function sendNow($notifiables, $notification, ?array $channels = null)
|
||||
{
|
||||
return (new NotificationDispatcher(
|
||||
$this,
|
||||
$this->container->make(Bus::class),
|
||||
$this->container->make(Dispatcher::class),
|
||||
$this->locale
|
||||
))->sendNow($notifiables, $notification, $channels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\NotificationTemplateService;
|
||||
use Illuminate\Notifications\NotificationSender;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 알림 발송 공통 디스패처
|
||||
*
|
||||
* 모든 채널의 모든 알림이 이 sendToNotifiable()을 통과합니다.
|
||||
*
|
||||
* 책임:
|
||||
* 1. 채널별 독립 발송 보장 (한 채널 실패가 다른 채널에 영향 없음)
|
||||
* 2. 발송 전후 G7 훅 실행 (core.notification.before_channel_send / after_channel_send / channel_send_failed)
|
||||
* 3. 플러그인/모듈이 훅으로 발송 로깅, 커스텀 처리 가능
|
||||
*/
|
||||
class NotificationDispatcher extends NotificationSender
|
||||
{
|
||||
/**
|
||||
* 개별 채널로 알림을 발송합니다.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @param string $id
|
||||
* @param mixed $notification
|
||||
* @param string $channel
|
||||
* @return mixed
|
||||
*/
|
||||
protected function sendToNotifiable($notifiable, $id, $notification, $channel)
|
||||
{
|
||||
$context = $this->buildContext($notifiable, $notification, $channel);
|
||||
|
||||
// Before 훅: 발송 전 처리 (로깅 준비, 필터링 등)
|
||||
HookManager::doAction('core.notification.before_channel_send', $channel, $context);
|
||||
|
||||
try {
|
||||
$result = parent::sendToNotifiable($notifiable, $id, $notification, $channel);
|
||||
|
||||
// After 훅: 발송 성공 (로깅, 통계 등)
|
||||
HookManager::doAction('core.notification.after_channel_send', $channel, $context);
|
||||
|
||||
return $result;
|
||||
} catch (\Exception $e) {
|
||||
// Failed 훅: 발송 실패 (에러 로깅 등)
|
||||
$context['error'] = $e->getMessage();
|
||||
HookManager::doAction('core.notification.channel_send_failed', $channel, $context);
|
||||
|
||||
Log::warning("알림 채널 '{$channel}' 발송 실패", [
|
||||
'channel' => $channel,
|
||||
'notification' => get_class($notification),
|
||||
'notifiable_id' => $notifiable->getKey(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 훅에 전달할 공통 컨텍스트를 구성합니다.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @param mixed $notification
|
||||
* @param string $channel
|
||||
* @return array
|
||||
*/
|
||||
private function buildContext($notifiable, $notification, string $channel): array
|
||||
{
|
||||
$context = [
|
||||
'notifiable' => $notifiable,
|
||||
'notifiable_id' => $notifiable->getKey(),
|
||||
'notifiable_type' => get_class($notifiable),
|
||||
'notifiable_uuid' => $notifiable->uuid ?? null,
|
||||
'notification_class' => get_class($notification),
|
||||
'recipient_identifier' => $notifiable->email ?? (string) ($notifiable->getKey() ?? ''),
|
||||
'recipient_name' => $notifiable->name ?? null,
|
||||
'recipient_user_id' => $notifiable->getKey() ?? null,
|
||||
];
|
||||
|
||||
// GenericNotification인 경우 추가 메타데이터
|
||||
if ($notification instanceof GenericNotification) {
|
||||
$context['notification_type'] = $notification->getType();
|
||||
$context['extension_type'] = $notification->getExtensionType();
|
||||
$context['extension_identifier'] = $notification->getExtensionIdentifier();
|
||||
$context['data'] = $notification->getData();
|
||||
|
||||
// 렌더링된 subject/body를 context에 포함 (로깅용)
|
||||
$rendered = $this->resolveRenderedContent($notification, $notifiable, $channel);
|
||||
if ($rendered) {
|
||||
$context['subject'] = $rendered['subject'] ?? null;
|
||||
$context['body'] = $rendered['body'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널별 렌더링된 제목/본문을 조회합니다.
|
||||
*
|
||||
* @param GenericNotification $notification
|
||||
* @param mixed $notifiable
|
||||
* @param string $channel
|
||||
* @return array{subject: string|null, body: string|null}|null
|
||||
*/
|
||||
private function resolveRenderedContent(GenericNotification $notification, $notifiable, string $channel): ?array
|
||||
{
|
||||
try {
|
||||
$templateService = app(NotificationTemplateService::class);
|
||||
$template = $templateService->resolve($notification->getType(), $channel);
|
||||
|
||||
if (! $template || ! $template->is_active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$locale = $notifiable->locale ?? app()->getLocale();
|
||||
|
||||
return $template->replaceVariables($notification->getData(), $locale);
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('NotificationDispatcher: 렌더링된 콘텐츠 조회 실패', [
|
||||
'type' => $notification->getType(),
|
||||
'channel' => $channel,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user