fix(notification): 비활성 확장이 제공하던 알림 채널을 발송 대상에서 제외

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

아울러 발송이 타임아웃·연결실패로 최종 실패할 때 pending 으로 방치되던
이력을 failed 로 마감한다(webhook 이 먼저 확정한 건은 덮어쓰지 않음).
This commit is contained in:
chym1217
2026-07-28 12:40:13 +09:00
parent cd11fc60ce
commit 8aa395c3a1
6 changed files with 186 additions and 5 deletions
+1
View File
@@ -80,6 +80,7 @@
- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다.
- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다.
- 문자·알림톡처럼 확장 기능이 추가하는 알림 발송 수단을 그 확장을 끈 뒤에도 발송 대상으로 삼아, 발송 이력에 "건너뜀"으로 남던 문제를 수정했습니다. 이제 확장을 끄면 해당 발송 수단은 발송 대상에서 완전히 제외되어 이력에도 남지 않고, 다시 켜면 알림 설정 그대로 복구됩니다.
## [7.0.5] - 2026-07-16
+39
View File
@@ -67,6 +67,14 @@ class GenericNotification extends BaseNotification
// 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인
if ($this->channel !== null) {
try {
// 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음).
// 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만,
// 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지
// 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님).
if (! $this->isChannelAvailable($this->channel)) {
return [];
}
// 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외
if ($this->isChannelBlockedForGuest($notifiable, $this->channel)) {
$this->logSkippedChannels([
@@ -126,6 +134,12 @@ class GenericNotification extends BaseNotification
$skippedChannels = [];
foreach ($channels as $channel) {
// 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이
// 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책).
if (! $this->isChannelAvailable($channel)) {
continue;
}
// 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외
if ($this->isChannelBlockedForGuest($notifiable, $channel)) {
$skippedChannels[] = [
@@ -194,6 +208,31 @@ class GenericNotification extends BaseNotification
}
}
/**
* 채널이 현재 사용 가능한(살아 있는) 채널 목록에 있는지 확인합니다.
*
* 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;
}
}
/**
* 비회원 수신자에게 해당 채널 발송이 차단되는지 확인합니다.
*
@@ -130,18 +130,39 @@ class SendMessageJob implements ShouldQueue
}
/**
* 모든 재시도 소진 후 최종 실패 시 로그를 남깁니다.
* 모든 재시도 소진 후 최종 실패 시 로그를 남기고 발송 이력을 실패로 마감합니다.
*
* 타임아웃·연결실패(ConnectionException)는 결과코드 없는 예외로 handle() 의 상태 갱신
* 분기를 우회하므로, 재시도 소진 후 이력이 pending 인 채 방치된다. 여기서 refkey 로 이력을
* 조회해 failed 로 마감한다(전송 실패라 최종 실패는 webhook 으로도 회수 불가). 이미 확정
* (success/failed)된 이력은 webhook 이 먼저 결과를 확정한 경우이므로 덮어쓰지 않는다(멱등).
*
* @param Throwable $exception 마지막으로 발생한 예외
*/
public function failed(Throwable $exception): void
{
$resultCode = $exception instanceof BizppurioApiException
? $exception->getResultCode()
: null;
Log::error('비즈뿌리오 발송 Job 최종 실패', [
'refkey' => $this->refkey,
'error' => $exception->getMessage(),
'result_code' => $exception instanceof BizppurioApiException
? $exception->getResultCode()
: null,
'result_code' => $resultCode,
]);
$dispatches = app(BizppurioDispatchRepositoryInterface::class);
$dispatch = $dispatches->findByRefkey($this->refkey);
// 이력 없음(비정상) 또는 이미 확정(webhook 선반영) → 마감 불필요(멱등)
if ($dispatch === null || $dispatch->status->isFinal()) {
return;
}
$dispatches->update($dispatch, [
'status' => DispatchStatus::Failed->value,
'result_code' => $resultCode,
'result_message' => $exception->getMessage(),
]);
}
}
@@ -2,6 +2,7 @@
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Jobs;
use Illuminate\Http\Client\ConnectionException;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
@@ -113,6 +114,55 @@ class SendMessageJobTest extends PluginTestCase
$this->assertTrue($job->afterCommit);
}
public function test_최종실패_콜백은_pending이력을_failed로_마감하고_사유를_기록한다(): void
{
// 타임아웃·연결실패는 결과코드 없는 ConnectionException 으로 재시도 소진 후 failed() 로 온다.
$dispatch = $this->seedPending('ref1');
$job = new SendMessageJob($this->payload, 'ref1');
$job->failed(new ConnectionException('cURL error 28: Connection timed out'));
$dispatch->refresh();
$this->assertSame(DispatchStatus::Failed, $dispatch->status);
$this->assertNull($dispatch->result_code); // 전송 실패라 결과코드 없음
$this->assertStringContainsString('timed out', (string) $dispatch->result_message);
}
public function test_최종실패_콜백은_bizppurio_api_exception의_결과코드를_보존한다(): void
{
$dispatch = $this->seedPending('ref1');
$job = new SendMessageJob($this->payload, 'ref1');
$job->failed(new BizppurioApiException('일시 오류', resultCode: '5003'));
$dispatch->refresh();
$this->assertSame(DispatchStatus::Failed, $dispatch->status);
$this->assertSame('5003', $dispatch->result_code);
}
public function test_이미_확정된_이력은_최종실패_콜백이_덮어쓰지_않는다(): void
{
// webhook 이 먼저 success 로 확정한 뒤 failed() 가 늦게 불려도 멱등해야 한다.
$dispatch = $this->seedPending('ref1');
$dispatch->update(['status' => DispatchStatus::Success->value, 'result_code' => '4000']);
$job = new SendMessageJob($this->payload, 'ref1');
$job->failed(new ConnectionException('timed out'));
$dispatch->refresh();
$this->assertSame(DispatchStatus::Success, $dispatch->status);
$this->assertSame('4000', $dispatch->result_code);
}
public function test_이력이_없으면_최종실패_콜백은_아무것도_하지_않는다(): void
{
// 이력 seed 없이 호출 — 예외 없이 조용히 종료해야 한다.
$job = new SendMessageJob($this->payload, 'missing-ref');
$job->failed(new ConnectionException('timed out'));
$this->assertDatabaseMissing('bizppurio_dispatches', ['refkey' => 'missing-ref']);
}
protected function tearDown(): void
{
Mockery::close();
@@ -223,4 +223,17 @@ class SmsChannelDriverTest extends PluginTestCase
Bus::assertNotDispatched(SendMessageJob::class);
}
public function test_정상_조건에서_발송하고_이력을_생성한다(): void
{
// 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다.
// 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다.
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->makeDriver($this->fakeTemplate())->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class);
$this->assertDatabaseCount('bizppurio_dispatches', 1);
}
}
@@ -164,15 +164,23 @@ class GenericNotificationViaTest extends TestCase
/**
* NotificationChannelService::isChannelEnabledForExtension()의 응답을 mocking.
*
* getAvailableChannels() 는 기본적으로 mail/database 만 반환하도록 mock 한다
* (비활성 확장 채널 제외 회귀 테스트가 available 목록을 재정의할 수 있도록).
*
* @param array<string, bool> $channelMap
* @param array<int, string>|null $availableIds available 채널 id 목록(미지정 시 mail/database)
*/
private function mockChannelEnabled(array $channelMap): void
private function mockChannelEnabled(array $channelMap, ?array $availableIds = null): void
{
$availableIds ??= ['mail', 'database'];
$mock = Mockery::mock(NotificationChannelService::class);
$mock->shouldReceive('isChannelEnabledForExtension')
->andReturnUsing(function ($type, $identifier, $channel) use ($channelMap) {
return $channelMap[$channel] ?? true;
});
$mock->shouldReceive('getAvailableChannels')
->andReturn(array_map(static fn (string $id) => ['id' => $id], $availableIds));
$this->app->instance(NotificationChannelService::class, $mock);
}
@@ -300,6 +308,55 @@ class GenericNotificationViaTest extends TestCase
$this->assertEmpty($channels);
}
/**
* 사용 가능한 채널 목록에 없는 채널(비활성 확장이 제공하던 채널)은 via() 가 제외한다.
*
* 회귀: 비즈뿌리오 등 확장이 비활성이면 그 확장의 채널(sms/alimtalk)이 getAvailableChannels()
* 에서 빠진다. 하지만 저장된 채널 설정·템플릿은 남아 있어, via() 가 available 목록을 확인하지
* 않으면 죽은 채널을 발송 후보로 넣고 "건너뜀" 로그까지 남긴다. available 목록에 없는 채널은
* 로그 없이 제외되어야 한다 (채널 지정 경로).
*/
public function test_via_with_explicit_channel_excludes_channel_not_in_available_list(): void
{
// available = mail/database 만 (sms 는 비활성 확장이라 목록에 없음)
$this->mockChannelEnabled(['sms' => true], availableIds: ['mail', 'database']);
$notification = new GenericNotification(
type: 'test_via_check',
hookPrefix: 'core.test',
data: [],
extensionType: 'core',
extensionIdentifier: 'core',
channel: 'sms'
);
$channels = $notification->via($this->user);
$this->assertEmpty($channels, '사용 가능 목록에 없는 sms 는 발송 후보에서 빠져야 한다.');
// 로그도 남지 않아야 한다 (건너뜀 기록 X)
$this->assertDatabaseMissing('notification_logs', ['channel' => 'sms']);
}
/**
* 다채널 자동 결정 경로에서도 available 목록에 없는 채널은 로그 없이 제외된다.
*/
public function test_via_multichannel_excludes_channel_not_in_available_list(): void
{
$this->createTemplate('mail');
$this->createTemplate('database');
// 정의 채널에 sms 를 포함시키되 available 목록에는 없게 한다
$this->definition->update(['channels' => ['mail', 'database', 'sms']]);
$this->mockChannelEnabled([], availableIds: ['mail', 'database']);
$notification = new GenericNotification('test_via_check', 'core.test');
$channels = $notification->via($this->user);
$this->assertNotContains('sms', $channels, '사용 가능 목록에 없는 sms 는 제외되어야 한다.');
$this->assertContains('mail', $channels);
$this->assertContains('database', $channels);
$this->assertDatabaseMissing('notification_logs', ['channel' => 'sms']);
}
/**
* 테스트용 템플릿 생성 헬퍼
*/