diff --git a/CHANGELOG.md b/CHANGELOG.md index d40dc639..ebd9dbad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ - 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다. - 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다. +- 문자·알림톡처럼 확장 기능이 추가하는 알림 발송 수단을 그 확장을 끈 뒤에도 발송 대상으로 삼아, 발송 이력에 "건너뜀"으로 남던 문제를 수정했습니다. 이제 확장을 끄면 해당 발송 수단은 발송 대상에서 완전히 제외되어 이력에도 남지 않고, 다시 켜면 알림 설정 그대로 복구됩니다. ## [7.0.5] - 2026-07-16 diff --git a/app/Notifications/GenericNotification.php b/app/Notifications/GenericNotification.php index b16d5c33..d21be8f4 100644 --- a/app/Notifications/GenericNotification.php +++ b/app/Notifications/GenericNotification.php @@ -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; + } + } + /** * 비회원 수신자에게 해당 채널 발송이 차단되는지 확인합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php index c039f494..ac24d448 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -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(), ]); } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php index 46dbe4d9..d97e6693 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -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(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index bc222347..407ba99e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -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); + } } diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php index d3822835..d544af73 100644 --- a/tests/Unit/Notifications/GenericNotificationViaTest.php +++ b/tests/Unit/Notifications/GenericNotificationViaTest.php @@ -164,15 +164,23 @@ class GenericNotificationViaTest extends TestCase /** * NotificationChannelService::isChannelEnabledForExtension()의 응답을 mocking. * + * getAvailableChannels() 는 기본적으로 mail/database 만 반환하도록 mock 한다 + * (비활성 확장 채널 제외 회귀 테스트가 available 목록을 재정의할 수 있도록). + * * @param array $channelMap + * @param array|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']); + } + /** * 테스트용 템플릿 생성 헬퍼 */