fix(message_bizppurio): 잔액부족 알림 본문 변수 미치환 수정 + 후불 한도초과 대응
잔액부족 관리자 알림 본문의 {channel_label}·{result_code} 등이 실제 값으로
치환되지 않고 중괄호째 노출되던 결함을 수정한다. balance.low 훅 인자와 사이트
메타를 알림 data 로 채우는 extract_data 구독 리스너(BalanceLowNotificationDataListener)
를 추가해, 코어 알림 본문 치환이 성립하도록 한다.
후불 한도 초과(9071)를 잔액부족과 동일 분류에 포함해 관리자 알림이 발화되도록
하고, 채널군 라벨(문자/알림톡)·결과코드 사유를 ko/en 및 ja 언어팩에 추가한다.
This commit is contained in:
@@ -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,
|
||||
];
|
||||
|
||||
+110
@@ -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'];
|
||||
|
||||
/**
|
||||
* 결과 코드를 카테고리로 분류합니다.
|
||||
|
||||
+127
@@ -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);
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user