fix(message_bizppurio): 잔액부족 알림 본문 변수 미치환 수정 + 후불 한도초과 대응

잔액부족 관리자 알림 본문의 {channel_label}·{result_code} 등이 실제 값으로
치환되지 않고 중괄호째 노출되던 결함을 수정한다. balance.low 훅 인자와 사이트
메타를 알림 data 로 채우는 extract_data 구독 리스너(BalanceLowNotificationDataListener)
를 추가해, 코어 알림 본문 치환이 성립하도록 한다.

후불 한도 초과(9071)를 잔액부족과 동일 분류에 포함해 관리자 알림이 발화되도록
하고, 채널군 라벨(문자/알림톡)·결과코드 사유를 ko/en 및 ja 언어팩에 추가한다.
This commit is contained in:
chym1217
2026-07-28 12:40:19 +09:00
parent f49d939ec1
commit 4b5ec6ebd6
13 changed files with 280 additions and 6 deletions
@@ -10,3 +10,5 @@
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공
- 환경설정·알림톡 템플릿 관리·발송 이력 등 관리자 화면 텍스트와 발송 결과 코드 안내의 일본어 번역 추가 — 관련 화면이 일본어 로케일에서 자연스럽게 표시됩니다.
- 후불 한도 초과(9071) 결과 코드 사유의 일본어 번역 추가.
- 잔액부족 알림의 채널군 라벨(문자/알림톡) 일본어 번역 추가.
@@ -72,4 +72,8 @@ return [
'cache' => [
'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。',
],
'channel_group' => [
'text' => '文字',
'alimtalk' => '通知トーク',
],
];
@@ -46,4 +46,5 @@ return [
'7523' => 'その他のエラー',
'9000' => '一時的なシステムエラー',
'9070' => '残高不足(メール)',
'9071' => '後払い限度額超過',
];
@@ -36,6 +36,7 @@
- 문자·알림톡 발송 시 비즈뿌리오에 실제로 전송한 요청 내용을 이력에 함께 저장합니다. 발송이 실패했을 때 어떤 내용이 전송됐는지 사후에 확인할 수 있습니다.
- 알림톡 템플릿이 연결되지 않았거나 문자 템플릿이 없는 등 발송 자체를 시도하지 못한 경우, 관리자 알림 발송 이력 화면에 "성공"이 아닌 "실패"로 정확히 표시됩니다.
- 플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다.
- 잔액 부족으로 발송이 실패해 관리자에게 보내는 알림에서, 실패한 채널(문자/알림톡)과 결과 코드가 안내 문구에 실제 값으로 표시됩니다. 후불 결제 한도를 초과한 경우도 잔액 부족과 동일하게 관리자에게 알립니다.
### Changed
@@ -8,6 +8,12 @@ return [
'alimtalk' => 'Alimtalk',
],
// 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용)
'channel_group' => [
'text' => 'SMS',
'alimtalk' => 'Alimtalk',
],
// 발송 상태 (DispatchStatus enum)
'status' => [
'pending' => 'Pending',
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
| Source: Bizppurio API manual "9. Status_Code" table
| As of: 2026-07-13 (plan appendix C-3, phase 1 scope)
| Scope: common(2000~9070) + SMS(4100~4443) + LMS(6600~6641) + Alimtalk(7000~7523).
| Scope: common(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + Alimtalk(7000~7523).
| RCS(8000s)/NaverTalkTalk(part of 5000s) are out of scope (follow-up D15).
|
*/
@@ -32,6 +32,7 @@ return [
'5005' => 'Temporary delivery error',
'9000' => 'Temporary system error',
'9070' => 'Insufficient balance (SMS)',
'9071' => 'Postpaid limit exceeded',
// ── SMS report ───────────────────────────────────────
'4100' => 'Success',
@@ -8,6 +8,12 @@ return [
'alimtalk' => '알림톡',
],
// 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용)
'channel_group' => [
'text' => '문자',
'alimtalk' => '알림톡',
],
// 발송 상태 (DispatchStatus enum)
'status' => [
'pending' => '대기',
@@ -9,7 +9,7 @@ declare(strict_types=1);
|
| 출처: 비즈뿌리오 API 매뉴얼 "9. Status_Code" 표
| 기준일: 2026-07-13 (계획서 부록 C-3 1차 범위)
| 범위: 공통(2000~9070) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523)
| 범위: 공통(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523)
| RCS(8000대)·네이버톡톡(5000대 일부)은 범위 밖(후속 D15).
|
| 매뉴얼이 삭제되어도 이 파일이 원본 스펙을 보존한다. 새 코드 추가 시 분류는
@@ -35,6 +35,7 @@ return [
'5005' => '일시적 전송 오류',
'9000' => '일시적 시스템 오류',
'9070' => '잔액 부족(문자)',
'9071' => '후불 한도 초과',
// ── SMS 리포트 ────────────────────────────────────────
'4100' => '성공',
@@ -13,6 +13,7 @@ use Database\Seeders\NotificationDefinitionSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist;
use Plugins\Sirsoft\MessageBizppurio\Listeners\BalanceLowNotificationDataListener;
use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener;
use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener;
use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener;
@@ -447,6 +448,7 @@ class Plugin extends AbstractPlugin
RegisterNotificationChannelsListener::class,
SeedChannelTemplatesListener::class,
GuestPhoneExtractListener::class,
BalanceLowNotificationDataListener::class,
LinkNotificationLogListener::class,
ValidateBizppurioSettingsListener::class,
];
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
/**
* 잔액부족 알림(bizppurio_balance_low)의 본문 변수를 채우는 extract_data 필터 리스너.
*
* 코어 알림 시스템은 템플릿 본문의 `{변수}` 를 extract_data 필터가 돌려준 `data` 값으로만
* 치환한다(app/Listeners/NotificationHookListener::dispatch). 잔액부족 알림은
* `sirsoft-message_bizppurio.balance.low` 훅으로 발화되는데, 그 훅 인자(결과코드·채널)를
* data 로 옮겨주는 구독이 없어 `{channel_label}`·`{result_code}` 등이 치환되지 않고
* 중괄호째 노출됐다. 이 리스너가 그 훅 인자와 사이트 메타를 data 로 채워 치환을 성립시킨다.
*
* 훅 인자 순서: (string $resultCode, string $channel) — WebhookReportService::notifyBalanceLowOnce.
*/
class BalanceLowNotificationDataListener implements HookListenerInterface
{
/** 이 리스너가 변수를 채우는 알림 유형 */
private const NOTIFICATION_TYPE = 'bizppurio_balance_low';
/** 잔액부족 알림 진입점(설정 페이지) 경로 */
private const SETTINGS_PATH = '/admin/plugins/sirsoft-message_bizppurio/settings';
/**
* 구독할 훅 목록 반환.
*
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-message_bizppurio.notification.extract_data' => [
'method' => 'injectBalanceLowData',
'priority' => 10,
'type' => 'filter',
],
];
}
/**
* 기본 핸들러 (미사용 — 필터 메서드로 처리).
*
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* 잔액부족 알림의 data 에 본문 치환 변수를 채웁니다.
*
* extract_data 결과(`{notifiable, notifiables, data, context}`)를 받아, 대상이
* 잔액부족 알림이면 훅 인자(결과코드·채널)와 사이트 메타를 data 에 덧붙여 반환한다.
* 그 외 유형은 원본을 그대로 통과시킨다. `name` 은 코어가 수신자별로 치환하도록
* `{recipient_name}` placeholder 를 넣는다(NotificationHookListener 폴백 규약).
*
* @param array<string, mixed> $result extract_data 결과
* @param string $type 알림 정의 유형
* @param array<int, mixed> $args 훅 원본 인수 ([$resultCode, $channel])
* @return array<string, mixed> 변수가 채워진(또는 원본) 결과
*/
public function injectBalanceLowData(array $result, string $type, array $args): array
{
if ($type !== self::NOTIFICATION_TYPE) {
return $result;
}
$resultCode = (string) ($args[0] ?? '');
$channel = (string) ($args[1] ?? '');
$result['data'] = array_merge(
$result['data'] ?? [],
[
'name' => '{recipient_name}',
'app_name' => (string) config('app.name', ''),
'result_code' => $resultCode,
'channel_label' => $this->channelLabel($channel),
'settings_url' => rtrim((string) config('app.url', ''), '/').self::SETTINGS_PATH,
'site_url' => (string) config('app.url', ''),
],
);
return $result;
}
/**
* 채널 문자열을 잔액부족 알림용 채널군 라벨(문자/알림톡)로 변환합니다.
*
* SMS/LMS 는 "문자" 로 묶고 알림톡은 "알림톡" 으로 표기한다. 알 수 없는 채널은
* 원본 문자열을 그대로 반환한다(치환 실패보다 원본 노출이 안전).
*
* @param string $channel 채널 문자열 (sms/lms/alimtalk)
* @return string 채널군 라벨
*/
private function channelLabel(string $channel): string
{
$enum = DispatchChannel::tryFrom($channel);
if ($enum === null) {
return $channel;
}
return $enum->isText()
? __('sirsoft-message_bizppurio::messages.channel_group.text')
: __('sirsoft-message_bizppurio::messages.channel_group.alimtalk');
}
}
@@ -34,9 +34,13 @@ class ResultCodeResolver
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'];
/**
* 잔액 부족 코드 — 문자 9070 / 알림톡 7436 (D3 자체 알림 대상).
* 잔액 부족 코드 — 선불 잔액부족 9070 / 알림톡 지갑 잔액부족 7436 /
* 후불 한도초과 9071 (D3 자체 알림 대상).
*
* 셋 다 "잔액·한도 소진으로 발송 불가" 성격이라 동일하게 관리자 자체 알림을 발화한다.
* 표시 사유는 result_codes lang 으로 각각 구분된다(9071 = 후불 한도 초과).
*/
private const BALANCE_LOW_CODES = ['9070', '7436'];
private const BALANCE_LOW_CODES = ['9070', '7436', '9071'];
/**
* 결과 코드를 카테고리로 분류합니다.
@@ -0,0 +1,127 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Listeners;
use Illuminate\Support\Facades\App;
use Plugins\Sirsoft\MessageBizppurio\Listeners\BalanceLowNotificationDataListener;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* BalanceLowNotificationDataListener — 잔액부족 알림 본문 변수 치환 데이터 주입 검증.
*
* 회귀 배경: extract_data 구독 부재로 `{channel_label}`·`{result_code}` 등이 치환되지 않고
* 중괄호째 노출되던 결함. 이 리스너가 훅 인자·사이트 메타를 data 로 채워 치환을 성립시킨다.
*/
class BalanceLowNotificationDataListenerTest extends PluginTestCase
{
private BalanceLowNotificationDataListener $listener;
protected function setUp(): void
{
parent::setUp();
App::setLocale('ko');
$this->listener = new BalanceLowNotificationDataListener;
}
private function emptyResult(): array
{
return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []];
}
/**
* 문자(sms) 잔액부족: channel_label="문자", result_code 주입 + 사이트 메타 채움.
*/
public function test_injects_variables_for_sms_balance_low(): void
{
$out = $this->listener->injectBalanceLowData(
$this->emptyResult(),
'bizppurio_balance_low',
['9070', 'sms'],
);
$data = $out['data'];
$this->assertSame('9070', $data['result_code']);
$this->assertSame('문자', $data['channel_label']);
$this->assertSame('{recipient_name}', $data['name']);
$this->assertNotSame('', $data['app_name']);
$this->assertStringContainsString('/admin/plugins/sirsoft-message_bizppurio/settings', $data['settings_url']);
$this->assertArrayHasKey('site_url', $data);
}
/**
* 알림톡(alimtalk) 잔액부족: channel_label="알림톡", result_code=7436.
*/
public function test_injects_variables_for_alimtalk_balance_low(): void
{
$out = $this->listener->injectBalanceLowData(
$this->emptyResult(),
'bizppurio_balance_low',
['7436', 'alimtalk'],
);
$this->assertSame('7436', $out['data']['result_code']);
$this->assertSame('알림톡', $out['data']['channel_label']);
}
/**
* lms 도 문자군으로 "문자" 라벨.
*/
public function test_lms_maps_to_text_label(): void
{
$out = $this->listener->injectBalanceLowData(
$this->emptyResult(),
'bizppurio_balance_low',
['9071', 'lms'],
);
$this->assertSame('문자', $out['data']['channel_label']);
$this->assertSame('9071', $out['data']['result_code']);
}
/**
* 다른 알림 유형은 원본을 그대로 통과(변수 미주입).
*/
public function test_ignores_other_notification_types(): void
{
$result = $this->emptyResult();
$out = $this->listener->injectBalanceLowData($result, 'order_confirmed', ['9070', 'sms']);
$this->assertSame($result, $out);
$this->assertArrayNotHasKey('result_code', $out['data']);
}
/**
* 알 수 없는 채널은 원본 문자열을 라벨로 유지(치환 실패보다 원본 노출).
*/
public function test_unknown_channel_keeps_raw(): void
{
$out = $this->listener->injectBalanceLowData(
$this->emptyResult(),
'bizppurio_balance_low',
['9999', 'unknown'],
);
$this->assertSame('unknown', $out['data']['channel_label']);
}
/**
* 주입된 data 로 알림 본문 템플릿의 변수가 실제 값으로 치환된다(엔드투엔드).
*/
public function test_injected_data_resolves_template_body(): void
{
$out = $this->listener->injectBalanceLowData(
$this->emptyResult(),
'bizppurio_balance_low',
['9071', 'alimtalk'],
);
$body = '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).';
$rendered = strtr($body, [
'{channel_label}' => $out['data']['channel_label'],
'{result_code}' => $out['data']['result_code'],
]);
$this->assertSame('비즈뿌리오 잔액 부족으로 알림톡 발송이 실패했습니다 (코드: 9071).', $rendered);
$this->assertStringNotContainsString('{', $rendered);
}
}
@@ -33,17 +33,26 @@ class ResultCodeResolverTest extends PluginTestCase
}
/**
* 잔액부족(9070 문자 / 7436 알림톡)은 BalanceLow + isBalanceLow true.
* 잔액부족(9070 문자 / 7436 알림톡 / 9071 후불 한도초과)은 BalanceLow + isBalanceLow true.
*/
public function test_balance_low_codes(): void
{
foreach (['9070', '7436'] as $code) {
foreach (['9070', '7436', '9071'] as $code) {
$this->assertSame(ResultCategory::BalanceLow, $this->resolver->categorize($code), "코드 {$code}");
$this->assertTrue($this->resolver->isBalanceLow($code), "코드 {$code}");
$this->assertFalse($this->resolver->isSuccess($code), "코드 {$code}");
}
}
/**
* 후불 한도초과(9071) 는 사유 문구가 lang 에 정의되어 코드만 노출되지 않는다.
*/
public function test_postpaid_limit_code_has_reason(): void
{
$this->assertNotNull($this->resolver->reason('9071'));
$this->assertStringContainsString('9071', $this->resolver->label('9071'));
}
/**
* 일시오류 코드는 Retry.
*