fix(message_bizppurio): 알림 채널 미저장 시 발송 차단 + 토글 저장·노출 정정

관리자가 SMS·알림톡 채널을 꺼도 발송이 계속되던 문제 수정.

- 원인: 채널 토글 저장이 map-only 라 미등록 확장 채널(sms/alimtalk)의
 on/off 가 저장되지 않았고, 백엔드는 미저장 채널을 활성으로 판정
- 프론트: 토글 저장을 upsert 로 전환(미저장 채널도 저장), 하단 채널 탭을
 source 기준으로 필터(확장 채널은 미저장/OFF 시 숨김) — 코어·게시판·이커머스
- 백엔드: 플러그인이 core.notification.channel_enabled 훅을 구독해
 확장 채널 미저장 시 OFF 로 게이트(코어 무수정, 코어 기본 채널은 하위호환 유지)
This commit is contained in:
chym1217 committed 2026-07-28 12:36:29 +09:00
1 parent 92f36e86b1
commit eb0c72c32b
9 files changed
+309 -6

No files matched your search

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Services\ModuleSettingsService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
/**
* 비즈뿌리오 문자·알림톡 채널을 코어 알림 시스템에 등록하는 필터 리스너.
@@ -70,6 +72,11 @@ class RegisterNotificationChannelsListener implements HookListenerInterface
'priority' => 20,
'type' => 'filter',
],
'core.notification.channel_enabled' => [
'method' => 'gateChannelEnabled',
'priority' => 20,
'type' => 'filter',
],
];
foreach (self::CHANNEL_HOOK_PREFIXES as $prefix) {
@@ -144,6 +151,94 @@ class RegisterNotificationChannelsListener implements HookListenerInterface
};
}
/**
* 우리 채널(sms/alimtalk)의 "미저장=비활성(OFF)" 정책을 적용합니다
* (core.notification.channel_enabled 필터).
*
* 코어의 기본 판정은 "채널 설정 엔트리가 없으면 활성(true)" 이다(하위호환). 하지만
* sms·alimtalk 은 플러그인이 나중에 주입한 채널이라, 관리자가 명시적으로 켜기 전에는
* 발송·기록하지 않아야 한다(opt-in). 게스트 발송 정책 isChannelGuestAllowed 의
* "확장 채널은 미선언=차단" 과 같은 방향이다.
*
* 따라서 우리 채널이면서 해당 확장의 notifications.channels 저장소에 엔트리가 없을 때만
* false 로 덮어쓴다. 엔트리가 저장되어 있으면(켜짐/꺼짐 모두) 코어가 이미 그 값을 반영해
* $enabled 로 넘겨주므로 그대로 통과시킨다. 우리 채널이 아니면 항상 원본을 통과시킨다.
*
* @param bool $enabled 코어가 계산한 활성 여부
* @param string $extensionType 확장 타입 (core/module/plugin)
* @param string|null $extensionIdentifier 확장 식별자
* @param string $channelId 채널 식별자
* @return bool 최종 활성 여부
*/
public function gateChannelEnabled(
bool $enabled,
string $extensionType,
?string $extensionIdentifier,
string $channelId
): bool {
if (! in_array($channelId, self::CHANNEL_IDS, true)) {
return $enabled;
}
// 저장 엔트리가 있으면 코어 판정($enabled)을 존중, 없으면 미저장 → OFF
if ($this->hasSavedChannelEntry($extensionType, $extensionIdentifier, $channelId)) {
return $enabled;
}
return false;
}
/**
* 해당 확장의 notifications.channels 저장소에 특정 채널 엔트리가 존재하는지 확인합니다.
*
* 저장소는 확장 타입별로 분리됩니다:
* - core → SettingsService
* - module → ModuleSettingsService
* - plugin → PluginSettingsService
*
* 조회 실패(예외)나 미지원 타입은 "미저장"(false)으로 간주해 안전측(OFF)으로 처리한다.
*
* @param string $extensionType 확장 타입
* @param string|null $extensionIdentifier 확장 식별자
* @param string $channelId 채널 식별자
* @return bool 저장 엔트리 존재 여부
*/
private function hasSavedChannelEntry(
string $extensionType,
?string $extensionIdentifier,
string $channelId
): bool {
try {
$channels = match ($extensionType) {
'core' => app(SettingsService::class)
->getSetting('notifications.channels', []),
'module' => empty($extensionIdentifier)
? []
: app(ModuleSettingsService::class)
->get($extensionIdentifier, 'notifications.channels', []),
'plugin' => empty($extensionIdentifier)
? []
: app(PluginSettingsService::class)
->get($extensionIdentifier, 'notifications.channels', []),
default => [],
};
} catch (\Throwable $e) {
return false;
}
if (! is_array($channels)) {
return false;
}
foreach ($channels as $entry) {
if (is_array($entry) && ($entry['id'] ?? null) === $channelId) {
return true;
}
}
return false;
}
/**
* 레거시 다채널 자동 결정 경로에서 채널 후보에 sms·alimtalk 을 더합니다.
*
@@ -6,6 +6,7 @@ use App\Extension\HookManager;
use App\Services\ChannelReadinessService;
use App\Services\NotificationChannelService;
use App\Services\PluginSettingsService;
use App\Services\SettingsService;
use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
@@ -48,6 +49,26 @@ class ChannelRegistrationHookTest extends PluginTestCase
fn ($result, $channelId) => $listener->checkReadiness($result, $channelId),
20,
);
HookManager::addFilter(
'core.notification.channel_enabled',
fn ($enabled, $extType, $extId, $channelId) => $listener->gateChannelEnabled($enabled, $extType, $extId, $channelId),
20,
);
}
/**
* 코어 SettingsService 의 notifications.channels 저장값을 지정합니다.
*
* @param array<int, array<string, mixed>> $channels
*/
private function setCoreChannels(array $channels): void
{
$settings = \Mockery::mock(SettingsService::class);
$settings->shouldReceive('getSetting')
->with('notifications.channels', [])
->andReturn($channels);
$this->app->instance(SettingsService::class, $settings);
app(NotificationChannelService::class)->clearChannelEnabledCache();
}
public function test_코어_채널서비스가_sms_alimtalk을_노출한다(): void
@@ -94,4 +115,50 @@ class ChannelRegistrationHookTest extends PluginTestCase
$this->assertTrue($result['ready']);
}
public function test_channel_enabled_미저장_sms는_off로_덮인다(): void
{
$this->registerListener();
// mail/database 만 저장, sms/alimtalk 엔트리 없음(미저장)
$this->setCoreChannels([
['id' => 'mail', 'is_active' => true],
['id' => 'database', 'is_active' => true],
]);
$service = app(NotificationChannelService::class);
// 우리 채널: 미저장 → OFF (opt-in)
$this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'sms'));
$service->clearChannelEnabledCache();
$this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'alimtalk'));
// 코어 기본 채널: 미저장이어도 ON 유지(하위호환)
$service->clearChannelEnabledCache();
$this->assertTrue($service->isChannelEnabledForExtension('core', 'core', 'mail'));
}
public function test_channel_enabled_저장된_on_sms는_on이다(): void
{
$this->registerListener();
$this->setCoreChannels([
['id' => 'mail', 'is_active' => true],
['id' => 'sms', 'is_active' => true],
]);
$this->assertTrue(
app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms')
);
}
public function test_channel_enabled_저장된_off_sms는_off이다(): void
{
$this->registerListener();
$this->setCoreChannels([
['id' => 'mail', 'is_active' => true],
['id' => 'sms', 'is_active' => false],
]);
$this->assertFalse(
app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms')
);
}
}
@@ -204,6 +204,7 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase
$this->assertArrayHasKey('core.notification.filter_available_channels', $hooks);
$this->assertArrayHasKey('core.notification.channel_readiness', $hooks);
$this->assertArrayHasKey('core.notification.channel_enabled', $hooks);
$this->assertArrayHasKey('core.auth.notification.channels', $hooks);
$this->assertArrayHasKey('sirsoft-ecommerce.notification.channels', $hooks);
$this->assertArrayHasKey('sirsoft-board.notification.channels', $hooks);
@@ -213,4 +214,14 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase
$this->assertSame('filter', $config['type']);
}
}
public function test_channel_enabled_우리채널이_아니면_원본을_통과시킨다(): void
{
$listener = $this->makeListener();
// mail/database 등 타 채널은 저장소 조회 없이 코어 판정($enabled)을 그대로 반환
$this->assertTrue($listener->gateChannelEnabled(true, 'core', 'core', 'mail'));
$this->assertFalse($listener->gateChannelEnabled(false, 'core', 'core', 'database'));
$this->assertTrue($listener->gateChannelEnabled(true, 'module', 'sirsoft-board', 'slack'));
}
}