Files
Gnuboard7/app/Notifications/GenericNotification.php
T
chym1217 8aa395c3a1 fix(notification): 비활성 확장이 제공하던 알림 채널을 발송 대상에서 제외
비활성 상태의 확장이 등록했던 발송 수단(문자·알림톡 등)을 코어 via가
저장된 채널 설정만 보고 계속 발송·"건너뜀" 로깅하던 문제를 수정한다.
사용 가능한 채널 목록에 없는 채널은 로그 없이 조용히 제외하며,
알림 설정·템플릿은 그대로 유지되어 확장을 다시 켜면 복구된다.

아울러 발송이 타임아웃·연결실패로 최종 실패할 때 pending 으로 방치되던
이력을 failed 로 마감한다(webhook 이 먼저 확정한 건은 덮어쓰지 않음).
2026-07-28 12:40:13 +09:00

413 lines
16 KiB
PHP

<?php
namespace App\Notifications;
use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
use App\Contracts\Notifications\GuestRecipientInterface;
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,
) {}
/**
* 훅 접두사 반환.
*/
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;
}
}