'홍길동', ...]) * @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, ) {} /** * 훅 접두사 반환. */ protected function getHookPrefix(): string { return $this->hookPrefix; } /** * 알림 유형 반환. */ protected function getNotificationType(): string { return $this->type; } /** * 발송 채널 결정 — notification_definitions.channels 기반 + readiness 필터. * * 1. definition.channels 조회 * 2. 훅 필터 적용 (플러그인 채널 추가/제거) * 3. readiness 필터 (미설정 채널 제외 + skipped 로깅) */ public function via(object $notifiable): array { // 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인 if ($this->channel !== null) { try { // 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음). // 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만, // 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지 // 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님). if (! $this->isChannelAvailable($this->channel)) { return []; } // 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외 if ($this->isChannelBlockedForGuest($notifiable, $this->channel)) { $this->logSkippedChannels([ ['channel' => $this->channel, 'reason' => 'notification.channel_guest_not_allowed'], ], $notifiable); return []; } // 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) { // 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이 // 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책). if (! $this->isChannelAvailable($channel)) { continue; } // 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외 if ($this->isChannelBlockedForGuest($notifiable, $channel)) { $skippedChannels[] = [ 'channel' => $channel, 'reason' => 'notification.channel_guest_not_allowed', ]; continue; } // 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; } } /** * 채널이 현재 사용 가능한(살아 있는) 채널 목록에 있는지 확인합니다. * * getAvailableChannels() 는 코어 기본 채널(mail/database)과 활성 확장이 filter 훅으로 추가한 * 채널만 담는다. 확장이 비활성/삭제되면 그 채널이 이 목록에서 자동으로 빠지므로, 특정 확장 * 이름을 알지 못해도 "죽은 채널"을 판별할 수 있다. 조회 실패 시(부팅 초기 등) 안전측으로 true * 를 반환해 기존 게이트(확장 토글·readiness)가 판정하도록 위임한다. * * @param string $channel 채널 식별자 * @return bool 사용 가능하면 true */ private function isChannelAvailable(string $channel): bool { try { $availableIds = array_column( app(NotificationChannelService::class)->getAvailableChannels(), 'id' ); return in_array($channel, $availableIds, true); } catch (\Throwable $e) { return true; } } /** * 비회원 수신자에게 해당 채널 발송이 차단되는지 확인합니다. * * 수신자가 게스트(GuestRecipientInterface)가 아니면 항상 false(차단 안 함). * 게스트면 채널의 allow_guest 정책을 조회해, 미허용 채널이면 true(차단)를 반환합니다. * * @param object $notifiable 수신자 * @param string $channel 채널 식별자 * @return bool true = 차단, false = 허용 */ private function isChannelBlockedForGuest(object $notifiable, string $channel): bool { if (! ($notifiable instanceof GuestRecipientInterface && $notifiable->isGuest())) { return false; } return ! app(NotificationChannelService::class)->isChannelGuestAllowed($channel); } /** * 미설정 채널 건너뛰기를 notification_logs에 기록합니다. * * @param array $skippedChannels [{channel, reason}] */ private function logSkippedChannels(array $skippedChannels, object $notifiable): void { // 게스트는 user FK 가 없으므로 recipient_user_id 는 null, 식별은 email 로 한다. $isGuest = $notifiable instanceof GuestRecipientInterface && $notifiable->isGuest(); $recipientUserId = $isGuest ? null : ($notifiable->getKey() ?? null); 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' => $recipientUserId, '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 채널 템플릿 조회. */ 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 = self::resolveNotifiableLocale($notifiable); $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 채널 템플릿 조회. */ public function toArray(object $notifiable): array { $templateService = app(NotificationTemplateService::class); $template = $templateService->resolve($this->type, 'database'); $locale = self::resolveNotifiableLocale($notifiable); 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); } /** * 알림 데이터 반환. */ public function getData(): array { return $this->data; } /** * 알림 타입 반환. */ public function getType(): string { return $this->type; } /** * 확장 타입 반환. */ public function getExtensionType(): string { return $this->extensionType; } /** * 확장 식별자 반환. */ public function getExtensionIdentifier(): string { return $this->extensionIdentifier; } }