fix(message_bizppurio): 결과코드 5002 언어팩 누락 + 알림톡 템플릿 저장 승인검증 부재 수정

PR 준비 최종 검토 중 문제코드 처리 대조표를 재검증하며 발견한 결함 2건.

- ResultCodeResolver 가 5002(요청 과다)를 재시도 대상으로 분류하지만 ko/en/ja
 언어팩 어디에도 사유가 없어 코드값만 노출되던 결함 수정
- 알림톡 템플릿 연결 저장이 승인 상태(RDY/ACT) 재검증 없이 그대로 upsert되어,
 화면 드롭다운(승인 템플릿만 노출)을 우회한 API 직접 호출 시 미승인 템플릿도
 저장 가능했던 결함 수정 — bind 에 카카오 승인 목록 대조 추가, 조회 자체
 실패 시에도 안전측으로 저장 거부
This commit is contained in:
chym1217
2026-07-28 16:56:13 +09:00
parent 40d3ce9639
commit 0a6a8d6968
16 changed files with 195 additions and 47 deletions
+4 -28
View File
@@ -67,11 +67,13 @@ class GenericNotification extends BaseNotification
// 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인
if ($this->channel !== null) {
try {
$channelService = app(NotificationChannelService::class);
// 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음).
// 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만,
// 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지
// 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님).
if (! $this->isChannelAvailable($this->channel)) {
if (! $channelService->isChannelAvailable($this->channel)) {
return [];
}
@@ -85,7 +87,6 @@ class GenericNotification extends BaseNotification
}
// 0단계: 확장 단위 채널 전역 활성 여부 확인
$channelService = app(NotificationChannelService::class);
if (! $channelService->isChannelEnabledForExtension(
$this->extensionType,
$this->extensionIdentifier,
@@ -136,7 +137,7 @@ class GenericNotification extends BaseNotification
foreach ($channels as $channel) {
// 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이
// 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책).
if (! $this->isChannelAvailable($channel)) {
if (! $channelService->isChannelAvailable($channel)) {
continue;
}
@@ -208,31 +209,6 @@ 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;
}
}
/**
* 비회원 수신자에게 해당 채널 발송이 차단되는지 확인합니다.
*
@@ -8,4 +8,4 @@
### Added
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다.
@@ -57,6 +57,7 @@ return [
'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)',
],
'send_skipped' => [
'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)',
@@ -24,6 +24,7 @@ return [
'4430' => 'スパム',
'4431' => '送信制限受信拒否(スパム)',
'4443' => 'スパムブロック',
'5002' => 'リクエストが多すぎます',
'5003' => '一時的な送信エラー',
'5004' => '一時的な送信エラー',
'5005' => '一時的な送信エラー',
@@ -9,6 +9,6 @@
### Added
- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다.
- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다.
- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다.
- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다.
- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다.
- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다.
@@ -144,12 +144,13 @@ HTTP/1.1 200
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 422 | Unprocessable Entity | `template_code` 가 카카오 승인 상태(RDY/ACT)가 아니거나, 카카오 승인 목록 조회 자체가 실패(자격증명 미설정·장애)한 경우 (`error.kakao_message`/`error.result_code`) |
<!-- @generated:end -->
**설명**
알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 승인되지 않은 템플릿 코드도 저장 자체는 가능하나(운영자 선택 자유), 편집 모달 드롭다운은 승인(RDY/ACT) 템플릿만 노출해 실수를 방지한다.
알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 저장 전 카카오 승인 상태(RDY/ACT)를 서버측에서 재검증하며, 미승인 코드거나 카카오 조회 자체가 실패하면 422 로 거부한다(편집 모달 드롭다운의 승인 템플릿 필터는 화면 단계일 뿐이라, 이를 우회한 직접 API 호출로 미승인 템플릿이 저장되는 것을 막기 위함). 연결 해제(`template_code` 빈 값)는 이 검증을 거치지 않는다.
### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates
@@ -80,6 +80,7 @@ return [
'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.',
'kakao_request_failed' => 'The Kakao management API request failed.',
'sender_key_missing' => 'Please set the alimtalk sender profile key first.',
'template_not_sendable' => 'This template is not in a sendable (approved) state. (code: :code)',
],
// Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log)
@@ -29,6 +29,7 @@ return [
'3010' => 'IP not in allowlist',
'3011' => 'Unknown error (Bizppurio)',
'3013' => 'Message not completed',
'5002' => 'Too many requests',
'5003' => 'Temporary delivery error',
'5004' => 'Temporary delivery error',
'5005' => 'Temporary delivery error',
@@ -80,6 +80,7 @@ return [
'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.',
'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.',
'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.',
'template_not_sendable' => '발송 가능(승인) 상태가 아닌 템플릿입니다. (코드: :code)',
],
// 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨)
@@ -32,6 +32,7 @@ return [
'3010' => '접속 허용 IP 불일치',
'3011' => '알 수 없는 오류(비즈뿌리오)',
'3013' => '완료 처리되지 않은 메시지',
'5002' => '요청 과다',
'5003' => '일시적 전송 오류',
'5004' => '일시적 전송 오류',
'5005' => '일시적 전송 오류',
@@ -83,15 +83,17 @@ class NotificationBindingController extends AdminBaseController
{
$validated = $request->validated();
$this->service->applyFromTemplateSave(
$validated['notification_type'],
$validated['template_code'] ?? null,
$validated['template_name'] ?? null,
(bool) ($validated['fallback_sms_enabled'] ?? false),
);
return $this->guard(function () use ($validated) {
$this->service->applyFromTemplateSave(
$validated['notification_type'],
$validated['template_code'] ?? null,
$validated['template_name'] ?? null,
(bool) ($validated['fallback_sms_enabled'] ?? false),
);
return ResponseHelper::success('messages.binding.saved', [
'bindings' => $this->service->all(),
]);
return ResponseHelper::success('messages.binding.saved', [
'bindings' => $this->service->all(),
]);
});
}
}
@@ -130,20 +130,49 @@ class NotificationBindingService
/**
* 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장).
*
* 저장 전 카카오 승인 상태(RDY/ACT)를 재검증한다 — 드롭다운이 승인 템플릿만 보여주지만
* 그 필터는 화면 단계일 뿐이라, API 를 직접 호출하면 미승인 템플릿도 저장될 수 있었다(회귀).
* 카카오 조회 자체가 실패(장애·자격증명 미설정)하면 승인 여부를 판정할 수 없으므로 안전측으로
* 저장을 거부한다 — 조회 실패를 "승인됨"으로 잘못 해석해 미승인 템플릿이 새는 것을 막는다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param array<string, mixed> $data template_code / template_name / fallback_sms_enabled
* @return BizppurioNotificationBinding 저장된 연동
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
*/
public function bind(string $notificationType, array $data): BizppurioNotificationBinding
{
$templateCode = (string) $data['template_code'];
$this->assertSendable($templateCode);
return $this->bindings->upsert($notificationType, self::CHANNEL, [
'template_code' => (string) $data['template_code'],
'template_code' => $templateCode,
'template_name' => (string) $data['template_name'],
'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false),
'is_active' => true,
]);
}
/**
* 템플릿 코드가 발송 가능(승인) 상태인지 검증합니다. 아니면 예외를 던집니다.
*
* @param string $templateCode 검증할 카카오 템플릿 코드
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
*/
private function assertSendable(string $templateCode): void
{
$sendableCodes = array_column($this->approvedTemplates(), 'template_code');
if (! in_array($templateCode, $sendableCodes, true)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.template_not_sendable', ['code' => $templateCode]),
);
}
}
/**
* 알림의 알림톡 연동을 해제(삭제)합니다.
*
@@ -184,6 +213,8 @@ class NotificationBindingService
* @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제)
* @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용)
* @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 (해제 시에는 미발생)
*/
public function applyFromTemplateSave(
string $notificationType,
@@ -5,7 +5,10 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Mockery;
use Mockery\MockInterface;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
@@ -20,6 +23,37 @@ class NotificationBindingEndpointTest extends PluginTestCase
{
private const BASE = '/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings';
/**
* AlimtalkTemplateService mock 을 컨테이너에 바인딩하고 반환한다.
*
* bind() 가 저장 전 카카오 승인 목록을 조회하므로(회귀 방지 재검증), 실제 kapi 호출 없이
* 저장 경로를 검증하려면 이 mock 으로 승인 템플릿 목록을 지정해야 한다.
*/
private function mockTemplateService(): MockInterface
{
$mock = Mockery::mock(AlimtalkTemplateService::class);
$this->app->instance(AlimtalkTemplateService::class, $mock);
return $mock;
}
/**
* 지정 template_code 목록을 승인(ACT) 상태로 반환하는 mock 을 등록한다.
*
* @param array<int, string> $codes 승인 처리할 template_code 목록
*/
private function stubApprovedTemplates(array $codes): void
{
$templates = array_map(
fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'],
$codes,
);
$this->mockTemplateService()
->shouldReceive('list')
->andReturn(['templates' => $templates, 'pagination' => []]);
}
/**
* 지정 권한 식별자들을 가진 admin 사용자를 만듭니다.
*
@@ -126,6 +160,7 @@ class NotificationBindingEndpointTest extends PluginTestCase
public function test_manage_권한으로_연동을_즉시_저장한다(): void
{
$this->stubApprovedTemplates(['TW_1236']);
$admin = $this->adminWith([
'sirsoft-message_bizppurio.messaging.view',
'sirsoft-message_bizppurio.messaging.manage',
@@ -147,6 +182,29 @@ class NotificationBindingEndpointTest extends PluginTestCase
]);
}
public function test_미승인_템플릿_코드로_저장하면_422이고_저장되지_않는다(): void
{
// 회귀: 화면 드롭다운은 승인 템플릿만 보여주지만, 그 필터를 우회해 API 를 직접
// 호출하면 미승인 template_code 도 저장되던 결함. bind() 서버측 재검증으로 차단.
$this->stubApprovedTemplates(['TW_OTHER']);
$admin = $this->adminWith([
'sirsoft-message_bizppurio.messaging.view',
'sirsoft-message_bizppurio.messaging.manage',
]);
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'notification_type' => 'welcome',
'template_code' => 'TW_UNAPPROVED',
'template_name' => '미승인',
]);
$response->assertStatus(422);
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'template_code' => 'TW_UNAPPROVED',
]);
}
public function test_빈_코드로_저장하면_연동을_해제한다(): void
{
BizppurioNotificationBinding::create([
@@ -163,6 +221,7 @@ class NotificationBindingEndpointTest extends PluginTestCase
'sirsoft-message_bizppurio.messaging.manage',
]);
// 해제는 카카오 승인 조회를 거치지 않으므로 mock 불필요.
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'notification_type' => 'welcome',
'template_code' => '',
@@ -182,6 +241,7 @@ class NotificationBindingEndpointTest extends PluginTestCase
'sirsoft-message_bizppurio.messaging.manage',
]);
// FormRequest 검증(notification_type required)이 서비스 호출보다 먼저 실패하므로 mock 불필요.
$response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
'template_code' => 'TW_1236',
]);
@@ -162,9 +162,22 @@ class NotificationBindingServiceTest extends PluginTestCase
$this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.');
}
/**
* bind() 승인 검증을 통과시키는 템플릿 목록으로 서비스를 만듭니다 (bind 계열 테스트 공용 fixture).
*
* @param array<int, string> $approvedCodes 승인(발송 가능) 처리할 template_code 목록
*/
private function makeServiceWithApprovedCodes(array $approvedCodes): NotificationBindingService
{
return $this->makeService(array_map(
fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'],
$approvedCodes,
));
}
public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void
{
$service = $this->makeService();
$service = $this->makeServiceWithApprovedCodes(['TW_1236']);
$service->bind('welcome', [
'template_code' => 'TW_1236',
@@ -189,7 +202,7 @@ class NotificationBindingServiceTest extends PluginTestCase
public function test_bind는_같은_알림에_대해_갱신한다(): void
{
$service = $this->makeService();
$service = $this->makeServiceWithApprovedCodes(['TW_1', 'TW_2']);
$service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']);
$service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']);
@@ -201,9 +214,53 @@ class NotificationBindingServiceTest extends PluginTestCase
]);
}
public function test_bind는_미승인_템플릿이면_예외를_던지고_저장하지_않는다(): void
{
// 회귀: bind() 가 승인 상태를 재검증하지 않아, 드롭다운(화면) 필터를 우회해 API 를
// 직접 호출하면 미승인 템플릿도 저장되던 결함.
$service = $this->makeServiceWithApprovedCodes(['TW_OTHER']);
$this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class);
try {
$service->bind('welcome', ['template_code' => 'TW_UNAPPROVED', 'template_name' => '미승인']);
} finally {
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'template_code' => 'TW_UNAPPROVED',
]);
}
}
public function test_bind는_카카오_조회_실패시_예외를_던지고_저장하지_않는다(): void
{
// 승인 여부를 판정할 수 없으면 안전측으로 저장을 거부한다(조회 실패="승인됨" 오인 방지).
$throwingTemplates = Mockery::mock(AlimtalkTemplateService::class);
$throwingTemplates->shouldReceive('list')
->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정'));
$service = new NotificationBindingService(
new BizppurioNotificationBindingRepository,
$throwingTemplates,
$this->fakeKakaoContent(),
);
$this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class);
try {
$service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '가입환영']);
} finally {
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
'template_code' => 'TW_1',
]);
}
}
public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void
{
$this->makeService()->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true);
$this->makeServiceWithApprovedCodes(['TW_9'])
->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true);
$this->assertDatabaseHas('bizppurio_notification_bindings', [
'notification_type' => 'welcome',
@@ -215,10 +272,10 @@ class NotificationBindingServiceTest extends PluginTestCase
public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void
{
$service = $this->makeService();
$service = $this->makeServiceWithApprovedCodes(['TW_1']);
$service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']);
// 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제
// 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 (승인 검증 대상 아님)
$service->applyFromTemplateSave('welcome', '', null, false);
$this->assertDatabaseMissing('bizppurio_notification_bindings', [
@@ -64,6 +64,19 @@ class ResultCodeResolverTest extends PluginTestCase
}
}
/**
* 재시도 대상으로 분류된 코드는 전부 사유가 lang 에 정의돼 있어야 한다(코드값만 노출 금지).
*
* 회귀: RETRYABLE_CODES 에 5002 가 분류돼 있었으나 lang/{ko,en}/result_codes.php 와 ja
* 번들에 5002 키가 누락되어 label('5002') 가 사유 없이 코드만 반환하던 결함.
*/
public function test_all_retryable_codes_have_reason(): void
{
foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) {
$this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락");
}
}
/**
* 일시오류 코드는 Retry.
*
@@ -181,6 +181,8 @@ class GenericNotificationViaTest extends TestCase
});
$mock->shouldReceive('getAvailableChannels')
->andReturn(array_map(static fn (string $id) => ['id' => $id], $availableIds));
$mock->shouldReceive('isChannelAvailable')
->andReturnUsing(fn (string $channel) => in_array($channel, $availableIds, true));
$this->app->instance(NotificationChannelService::class, $mock);
}