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 */ private function buildContext($notifiable, $notification, string $channel): array { // 비회원(게스트)은 users FK 가 없으므로 recipient_user_id 는 null, 식별은 email 로 한다. // (게스트의 getKey() 는 "guest:{hash}" 문자열이라 정수 FK 컬럼에 넣으면 로그 기록이 실패한다) $isGuest = $notifiable instanceof GuestRecipientInterface && $notifiable->isGuest(); $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' => $isGuest ? null : ($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 mixed $notifiable * @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 = BaseNotification::resolveNotifiableLocale($notifiable); return $template->replaceVariables($notification->getData(), $locale); } catch (\Throwable $e) { Log::debug('NotificationDispatcher: 렌더링된 콘텐츠 조회 실패', [ 'type' => $notification->getType(), 'channel' => $channel, 'error' => $e->getMessage(), ]); return null; } } }