Merge pull request from gnuboard/chym1217/issue28

feat(message_bizppurio): 비즈뿌리오 SMS·카카오 알림톡 메시징 플러그인
This commit is contained in:
eunhye
2026-07-28 17:31:46 +09:00
committed by GitHub
173 changed files with 22005 additions and 114 deletions
+3
View File
@@ -8,6 +8,8 @@
### Added
- 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다.
#### 사이트맵
- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.)
@@ -78,6 +80,7 @@
- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다.
- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다.
- 문자·알림톡처럼 확장 기능이 추가하는 알림 발송 수단을 그 확장을 끈 뒤에도 발송 대상으로 삼아, 발송 이력에 "건너뜀"으로 남던 문제를 수정했습니다. 이제 확장을 끄면 해당 발송 수단은 발송 대상에서 완전히 제외되어 이력에도 남지 않고, 다시 켜면 알림 설정 그대로 복구됩니다.
## [7.0.5] - 2026-07-16
+11 -2
View File
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Auth;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -11,6 +12,8 @@ class RegisterRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool 항상 true (권한은 미들웨어 체인에서 처리)
*/
public function authorize(): bool
{
@@ -20,7 +23,7 @@ class RegisterRequest extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
@@ -29,7 +32,9 @@ class RegisterRequest extends FormRequest
'nickname' => 'nullable|string|max:50',
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
'password' => 'required|string|min:8|confirmed',
'language' => 'nullable|string|in:' . implode(',', config('app.supported_locales', ['ko', 'en'])),
'mobile' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'],
'phone' => ['nullable', 'string', 'max:20', 'regex:/^[0-9\-\+\(\)\s]+$/'],
'language' => 'nullable|string|in:'.implode(',', config('app.supported_locales', ['ko', 'en'])),
'agree_terms' => 'accepted',
'agree_privacy' => 'accepted',
];
@@ -55,6 +60,10 @@ class RegisterRequest extends FormRequest
'password.required' => __('validation.auth.password.required'),
'password.min' => __('validation.auth.password.min'),
'password.confirmed' => __('validation.auth.password.confirmed'),
'mobile.max' => __('validation.auth.mobile.max'),
'mobile.regex' => __('validation.auth.mobile.regex'),
'phone.max' => __('validation.auth.phone.max'),
'phone.regex' => __('validation.auth.phone.regex'),
'agree_terms.accepted' => __('validation.auth.agree_terms.accepted'),
'agree_privacy.accepted' => __('validation.auth.agree_privacy.accepted'),
];
+16 -1
View File
@@ -67,6 +67,16 @@ class GenericNotification extends BaseNotification
// 대상 채널이 지정된 경우 (template별 발송) — 게스트 게이트 + 확장 토글 + readiness 확인
if ($this->channel !== null) {
try {
$channelService = app(NotificationChannelService::class);
// 사용 가능한 채널 목록에 없는 채널은 발송 후보에서 제외한다 (로그도 남기지 않음).
// 채널을 제공하던 확장이 비활성/삭제되면 그 채널이 getAvailableChannels() 에서 빠지지만,
// 저장된 채널 설정·템플릿은 남아 있어 죽은 채널로 발송·"건너뜀" 로깅이 발생한다. 살아 있지
// 않은 채널은 "존재하지 않는 것"으로 취급해 조용히 제외한다(skipped 기록 대상 아님).
if (! $channelService->isChannelAvailable($this->channel)) {
return [];
}
// 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외
if ($this->isChannelBlockedForGuest($notifiable, $this->channel)) {
$this->logSkippedChannels([
@@ -77,7 +87,6 @@ class GenericNotification extends BaseNotification
}
// 0단계: 확장 단위 채널 전역 활성 여부 확인
$channelService = app(NotificationChannelService::class);
if (! $channelService->isChannelEnabledForExtension(
$this->extensionType,
$this->extensionIdentifier,
@@ -126,6 +135,12 @@ class GenericNotification extends BaseNotification
$skippedChannels = [];
foreach ($channels as $channel) {
// 사용 가능한 채널 목록에 없는 채널(비활성/삭제된 확장이 제공하던 채널)은 로그 없이
// 조용히 제외한다 — 죽은 채널을 "건너뜀"으로 기록하지 않는다(채널 지정 경로와 동일 정책).
if (! $channelService->isChannelAvailable($channel)) {
continue;
}
// 게스트 게이트: 비회원 수신자인데 채널이 게스트 발송 미허용이면 제외
if ($this->isChannelBlockedForGuest($notifiable, $channel)) {
$skippedChannels[] = [
+2
View File
@@ -196,6 +196,8 @@ class AuthService
'nickname' => $data['nickname'] ?? null,
'email' => $data['email'],
'password' => Hash::make($data['password']),
'mobile' => $data['mobile'] ?? null,
'phone' => $data['phone'] ?? null,
'language' => $data['language'] ?? 'ko',
'country' => $this->detectCountryFromAcceptLanguage(),
'ip_address' => request()->ip(),
+2 -1
View File
@@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
<!-- @generated:start:api-readme-extensions -->
- **확장 수**: 9 · **엔드포인트 수**: 372
- **확장 수**: 10 · **엔드포인트 수**: 384
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
| --- | --- | --- | --- |
@@ -212,6 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 12 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 |
+2
View File
@@ -1501,6 +1501,8 @@ HTTP/1.1 200
| nickname | body | string | 아니오 | max 50 | 닉네임 |
| email | body | string | 예 | max 255 | 이메일 주소 |
| password | body | string | 예 | min 8 | 비밀번호 |
| mobile | body | string | 아니오 | max 20, `숫자 - + ( ) 공백` | 휴대폰번호 (선택) |
| phone | body | string | 아니오 | max 20, `숫자 - + ( ) 공백` | 전화번호 (선택) |
| language | body | string | 아니오 | `ko`, `en`, `fr`, `ja` | 언어 코드 |
| agree_terms | body | string | 아니오 | — | 이용약관 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) |
| agree_privacy | body | string | 아니오 | — | 개인정보 처리방침 동의 (코어 필수 동의 — accepted 규칙, 미동의 시 가입 거부) |
@@ -11,6 +11,7 @@
- 확장 식별자·메뉴 역할·활동 로그 검색 조건의 안내 문구 일본어 번역 추가 — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 모듈·플러그인 파일 업로드 시 허용되지 않는 파일 형식 안내 문구 일본어 번역 추가.
- 플러그인을 GitHub 주소 또는 ZIP 파일로 설치할 때의 입력 검증 안내 문구 일본어 번역 추가 — 주소 형식이 잘못됐거나 ZIP 이 아닌 파일을 올렸을 때, 파일 용량을 초과했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 회원가입 검증 메시지의 휴대폰·전화번호 항목(`validation.auth.mobile.*`, `validation.auth.phone.*`) 일본어 번역 추가 — 회원가입 시 연락처 형식 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.6] - 2026-07-22
@@ -721,6 +721,14 @@ return [
'required' => 'トークンは必須です。',
'string' => 'トークンは文字列である必要があります。',
],
'mobile' => [
'max' => '携帯電話番号は :max文字を超えることはできません。',
'regex' => '携帯電話番号は数字と -, +, (), スペースのみ入力できます。',
],
'phone' => [
'max' => '電話番号は :max文字を超えることはできません。',
'regex' => '電話番号は数字と -, +, (), スペースのみ入力できます。',
],
],
'asset' => [
'identifier' => [
@@ -0,0 +1,11 @@
# Changelog
이 언어팩의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-28
### Added
- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다.
@@ -0,0 +1,6 @@
<?php
return [
'action' => [],
'description' => [],
];
@@ -0,0 +1,80 @@
<?php
return [
'channel' => [
'sms' => 'SMS',
'lms' => 'LMS',
'alimtalk' => 'アラートトーク',
],
'status' => [
'pending' => '待機中',
'sent' => '送信中',
'success' => '成功',
'failed' => '失敗',
],
'source' => [
'auto' => '自動',
'manual' => '手動',
'bulk' => '一括',
],
'result_category' => [
'success' => '成功',
'retry' => '再試行',
'permanent_failure' => '永久失敗',
'balance_low' => '残高不足',
],
'channels' => [
'source_label' => 'ビズプリオ',
'sms' => [
'name' => 'SMS/LMSテキスト',
'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。',
],
'alimtalk' => [
'name' => 'カカオアラートトーク',
'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。',
],
],
'readiness' => [
'sms_credentials_missing' => 'ビズプリオのアイディとパスワードを設定してください。',
'sms_sender_number_missing' => '発信番号を設定してください。',
'alimtalk_api_key_missing' => 'カカオ管理API キーを設定してください。',
'alimtalk_sender_key_missing' => 'アラートトーク発信プロフィールキーを設定してください。',
],
'settings' => [
'bizppurio_id_attribute' => 'ビズプリオアイディ',
'password_attribute' => 'パスワード',
'sender_number_attribute' => '発信番号',
],
'webhook' => [
'received' => 'レポートを受け取りました。',
],
'error' => [
'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。',
'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。',
'send_failed' => 'メッセージ送信リクエストに失敗しました。',
'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)',
'invalid_response' => 'ビズプリオレスポンスを解析できません。',
'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)',
],
'send_skipped' => [
'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)',
'alimtalk_kakao_content_unavailable' => 'カカオ承認テンプレート内容を照会できないため送信をスキップしました。(通知タイプ: :type)',
'sms_template_missing' => 'SMSテンプレートがないため送信をスキップしました。(通知タイプ: :type)',
'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
],
'binding' => [
'saved' => 'アラートトーク連携を保存しました。',
'removed' => 'アラートトーク連携を解除しました。',
],
'cache' => [
'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。',
],
'channel_group' => [
'text' => '文字',
'alimtalk' => '通知トーク',
],
];
@@ -0,0 +1,55 @@
<?php
return [
'1000' => '成功',
'2000' => 'メッセージが無効です',
'3001' => '認証情報が無効です(Basic)',
'3002' => 'トークンが無効です(期限切れ·廃止)',
'3003' => 'IPが無効です',
'3004' => 'アカウントが無効です',
'3005' => '認証情報が無効です(Bearer)',
'3006' => 'アカウントが存在しません',
'3007' => 'アカウントパスワードが無効です',
'3009' => 'アカウントが停止状態です',
'3010' => 'アクセス許可IPが一致しません',
'3011' => '不明なエラー(bizppurio)',
'3013' => '完了処理されていないメッセージ',
'4100' => '成功',
'4400' => '電波の弱い地域',
'4401' => '電源オフ',
'4402' => 'ストレージ超過',
'4410' => '不正な番号',
'4414' => '結番·停止',
'4420' => 'その他端末エラー',
'4430' => 'スパム',
'4431' => '送信制限受信拒否(スパム)',
'4443' => 'スパムブロック',
'5002' => 'リクエストが多すぎます',
'5003' => '一時的な送信エラー',
'5004' => '一時的な送信エラー',
'5005' => '一時的な送信エラー',
'6600' => '成功',
'6603' => '電波の弱い地域',
'6604' => '電源オフ',
'6606' => '不正な番号',
'6621' => 'メッセージ長超過',
'6641' => 'スパムブロック',
'7000' => '成功',
'7103' => '発信プロフィールキーが無効です',
'7106' => '削除された発信キー',
'7107' => 'ブロックされた発信キー',
'7204' => 'メッセージ内容がテンプレートと不一致です',
'7206' => 'シリアルナンバー形式が不一致です',
'7306' => 'カカオシステムエラー',
'7307' => '処理遅延',
'7308' => '電話番号エラー',
'7320' => '受信ブロック',
'7325' => '変数長超過',
'7421' => 'タイムアウト',
'7436' => 'ウォレット残高不足(アラートトーク)',
'7437' => 'メッセージリクエスト失敗',
'7523' => '080受信拒否(スパム)',
'9000' => '一時的なシステムエラー',
'9070' => '残高不足(SMS)',
'9071' => '後払い限度額超過',
];
@@ -0,0 +1,218 @@
{
"name": "Biznorio メッセージ発送",
"description": "Biznorio 連動 SMS/LMS・カカオ アラート톡 発送プラグインです。",
"settings": {
"title": "Biznorio メッセージ発送 設定",
"description": "Biznorio 連動情報と発送 設定を管理します。",
"save": "保存",
"saving": "保存 中...",
"save_success": "設定が保存されました。",
"save_failed": "設定 保存に失敗しました。",
"test_mode": {
"label": "検査モード",
"hint": "検査モードでは Biznorio 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。",
"account_notice": "検査と運用は別の Biznorio アカウントで運営することをお勧めします。"
},
"live_mode_warning_title": "運用環境で発送されます",
"live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラート톡が発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。",
"sections": {
"api": {
"title": "API 連動",
"description": "発送システムとカカオ管理システムに共通に使用する連動情報です。"
},
"sending": {
"title": "発送 設定",
"description": "文字・アラート톡発送に使用する発信情報です。"
}
},
"fields": {
"bizppurio_id": {
"label": "Biznorio ID",
"hint": "発送・カカオ管理に共通に使用する Biznorio ID です。"
},
"password": {
"label": "パスワード",
"hint": "発送トークン発行に使用する Biznorio パスワードです。"
},
"api_key": {
"label": "API キー",
"hint": "API キーは Biznorio カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
},
"sender_number": {
"label": "発信番号",
"hint": "文字・アラート톡発送に使用する発信電話番号です。"
},
"sender_key": {
"label": "アラート톡 発信プロファイル キー",
"hint": "アラート톡 発送・テンプレート確認に使用する発信プロファイル キー(40文字)です。"
},
"template_cache_minutes": {
"label": "アラート톡 内容 キャッシュ 時間(分)",
"hint": "カカオ テンプレート内容をこの時間だけ再利用します。0 = 毎回最新(発送が多い場合は非推奨)。",
"clear_cache": "キャッシュ 初期化",
"clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。",
"clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。",
"clear_cache_failed": "キャッシュ 初期化に失敗しました。"
}
},
"report": {
"section_title": "レポート受信 設定",
"hint": "Biznorio は文字・アラート톡発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを Biznorio ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
"note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。",
"copy": "コピー",
"copied": "レポート受信アドレスがクリップボードにコピーされました。"
},
"cache": {
"section_title": "アラート톡 発送内容 キャッシュ",
"section_description": "アラート톡は発送する際、カカオに登録されたテンプレート内容(本文・ボタン)をそのまま送信する必要があります。発送するたびにカカオに内容をリクエストしないよう、一度取得した内容を一定時間再利用(キャッシュ)します。これにより、発送が多くてもカカオ確認の制限に引っかからず、素早く発送されます。"
},
"tabs": {
"connection": "環境設定",
"templates": "アラート톡 テンプレート"
},
"preparation": {
"intro": "文字・カカオ アラート톡を発送するには、まず Biznorio コンソールで以下の事前準備を完了する必要があります。",
"sms_label": "文字(SMS/LMS)",
"sms_sender": "発信番号を登録してください。",
"kakao_label": "カカオ アラート톡",
"kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。",
"kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。",
"kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
"console_link": "Biznorio コンソールを開く"
}
},
"binding": {
"section_title": "カカオ アラート톡 連動",
"list_guide": "各 通知に発送するカカオ アラート톡 テンプレートを接続してください。[接続]で承認されたテンプレートを指定すると、該当イベント発生時にアラート톡が自動発送されます。",
"section_hint": "承認されたアラート톡 テンプレートのみ接続できます。保存するとこの 通知に即座に反映されます。",
"modal_title": "アラート톡 接続 · {name}",
"unbound": "未接続",
"unavailable": "使用不可 — 再接続が必要",
"btn_connect": "接続",
"btn_change": "接続を変更",
"fallback_on": "SMS 代替 ON",
"fallback_off": "SMS 代替 OFF",
"connected_template": "接続テンプレート",
"none": "接続しない",
"no_approved_templates": "発送可能な(承認された)アラート톡 テンプレートがありません。まずテンプレートを登録・検査してください。",
"templates_load_failed": "テンプレート リストを読み込みできませんでした。プラグイン 設定(認証情報)を確認してください。",
"fallback_sms": "失敗時 SMS で代替発送",
"fallback_hint": "アラート톡 発送が失敗すると、この 通知の本文内容が文字(SMS)で代わりに発送されます。",
"variables_hint": "提供変数(発送時に自動置換)",
"saved": "アラート톡 連動を保存しました。",
"save_error": "アラート톡 連動 保存に失敗しました。"
},
"banner": {
"not_ready": "発送に必要な 設定が完了していません。",
"setup_action": "設定する",
"test_mode": "検査モードです — 実際の発送は行われません。"
},
"dispatch_result": {
"column_header": "文字・アラート톡 結果",
"inspection_label": "検査",
"low_balance": "残高不足",
"fallback": "SMS 代替発送 {status}",
"detail_title": "文字・アラート톡 発送 結果",
"channel_label": "発送チャネル: {channel}",
"sent_content_label": "実際の発送内容",
"sent_content_hint": "アラート톡 はカカオ承認テンプレートの実際の内容で発送され、上記の\"本文\"と異なる場合があります。"
},
"editor": {
"data_source": {
"dispatch_results": "ビズプリオ送信結果"
}
},
"templates": {
"title": "アラームトークテンプレート",
"description": "カカオアラームトークテンプレートを登録·審査·管理します。承認されたテンプレートのみアラーム連動に使用できます。",
"readiness": {
"title": "アラームトークテンプレートを使用するには、以下の設定が必要です",
"go_settings": "環境設定に移動",
"missing_label": "未設定項目",
"api_key_missing": "カカオ管理API キー",
"sender_key_missing": "アラームトーク送信プロフィールキー",
"note": "上記項目を環境設定タブで入力すると、テンプレート照会·登録が可能になります。実際の送信は審査モードをオフにして運用に切り替えた後、カカオ承認を受けたテンプレートのみ使用できます。"
},
"list_error": {
"title": "テンプレート一覧を読み込めませんでした"
},
"list_notice": {
"console_desc": "テンプレートの登録·編集·審査はビズプリオコンソールで進めてください。",
"console_link": "ビズプリオコンソールを開く"
},
"list": {
"refresh": "更新",
"search": "検索",
"search_placeholder": "テンプレート名検索(2~50文字)",
"filter_all": "すべてのステータス",
"empty": "登録されたアラームトークテンプレートがありません。",
"empty_hint": "ビズプリオコンソールで登録したテンプレートがここに表示されます。",
"load_failed": "テンプレート一覧を読み込めませんでした。送信プロフィールキーとAPI キーを確認してください。",
"columns": {
"no": "番号",
"name": "テンプレート名",
"code": "コード",
"status": "ステータス",
"requested_at": "登録申請日",
"processed_at": "処理日",
"actions": "内容"
}
},
"status": {
"sendable": "送信可能",
"inspecting": "審査中",
"rejected": "却下",
"uninspected": "未審査",
"stopped": "停止",
"blocked": "ブロック",
"dormant": "休止",
"unknown": "不明"
},
"status_sub": {
"rdy": "(使用前)"
},
"status_guide": {
"title": "ステータスバッジ",
"sendable_label": "送信可能",
"sendable": "承認完了。このステータスのみアラームに接続·送信できます。",
"inspecting_label": "審査中",
"inspecting": "カカオ審査進行中(営業日2~3日)。",
"pending_label": "未審査·却下",
"pending": "まだ送信できません。コンソールで審査申請·修正してください。"
},
"link_type": {
"WL": "ウェブリンク",
"AL": "アプリリンク",
"DS": "配送追跡",
"BK": "ボットキーワード",
"MD": "メッセージ転送",
"AC": "チャネル追加",
"BC": "相談トーク転換",
"BT": "ボット転換",
"TN": "電話をかける",
"MP": "地図表示",
"P1": "画像セキュア送信",
"P2": "個人情報利用",
"P3": "ワンクリック決済"
},
"actions": {
"detail": "詳細"
},
"detail": {
"title": "テンプレート詳細",
"close": "閉じる",
"buttons": "ボタン",
"extra": "付加情報",
"category": "カテゴリ",
"code": "テンプレートコード",
"content": "テンプレート内容",
"emphasize_type": "テンプレート種別",
"image_upload": "画像添付",
"subtitle_field": "補助文句",
"title_field": "強調表記文句",
"type_image": "画像形",
"type_none": "基本形",
"type_text": "強調表記形"
}
}
}
@@ -0,0 +1,30 @@
{
"identifier": "g7-plugin-sirsoft-message_bizppurio-ja",
"namespace": "g7",
"vendor": "sirsoft",
"name": {
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩",
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack",
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック"
},
"description": {
"ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩 (번들)",
"en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack (bundled)",
"ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック(バンドル)"
},
"version": "1.0.0",
"license": "MIT",
"scope": "plugin",
"target_identifier": "sirsoft-message_bizppurio",
"locale": "ja",
"locale_name": "Japanese",
"locale_native_name": "日本語",
"text_direction": "ltr",
"g7_version": ">=7.0.0-beta.4",
"requires": {
"target_version": null,
"depends_on_core_locale": true
},
"github_url": "",
"github_changelog_url": ""
}
@@ -0,0 +1,4 @@
{
"name": "Bizppurio メッセージ発送",
"description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。"
}
@@ -0,0 +1,18 @@
{
"bizppurio_balance_low": {
"definition": {
"name": "Bizssprrio残高不足",
"description": "Bizssprrio ウォレット残高が不足しており、SMS/アラートトーク送信に失敗した場合、管理者に送信されます"
},
"templates": {
"mail": {
"subject": "[{app_name}] Bizssprrio 残高が不足しています",
"body": "{name}様、Bizssprrio ウォレット残高が不足しているため、{channel_label} 送信に失敗しました (コード: {result_code})。チャージ後、送信が正常化されます。"
},
"database": {
"subject": "Bizssprrio残高不足",
"body": "Bizssprrio残高不足により、{channel_label} 送信に失敗しました (コード: {result_code})。"
}
}
}
}
@@ -0,0 +1,18 @@
{
"sirsoft-message_bizppurio": {
"name": "ビズプリオ メッセージ発送",
"description": "ビズプリオ メッセージ発送プラグインが提供する権限"
},
"sirsoft-message_bizppurio.messaging": {
"name": "メッセージ発送",
"description": "メッセージ発送ドメイン権限 (閲覧·管理)"
},
"sirsoft-message_bizppurio.messaging.view": {
"name": "メッセージ閲覧",
"description": "発送履歴·アラートトークテンプレート閲覧 (モニタリング)"
},
"sirsoft-message_bizppurio.messaging.manage": {
"name": "メッセージ管理",
"description": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理"
}
}
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.2] - 2026-07-22
### Added
- 회원가입 화면의 휴대폰번호·전화번호 입력란 라벨·안내 문구(`auth.mobile`, `auth.phone`) 일본어 번역 추가 — 회원가입 시 연락처 입력란이 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.1] - 2026-07-08
### Added
@@ -146,5 +146,9 @@
"divider": "または",
"continue": "非会員として注文する",
"hint": "会員登録なしで注文できます。"
}
},
"mobile": "携帯電話番号",
"mobile_placeholder": "携帯電話番号を入力してください (選択)",
"phone": "電話番号",
"phone_placeholder": "電話番号を入力してください (選択)"
}
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
},
"version": "1.0.1",
"version": "1.0.2",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-basic",
+8
View File
@@ -781,6 +781,14 @@ return [
'nickname' => [
'max' => 'Nickname may not be greater than :max characters.',
],
'mobile' => [
'max' => 'Mobile number may not be greater than :max characters.',
'regex' => 'Mobile number may only contain digits and -, +, (), spaces.',
],
'phone' => [
'max' => 'Phone number may not be greater than :max characters.',
'regex' => 'Phone number may only contain digits and -, +, (), spaces.',
],
'agree_terms' => [
'accepted' => 'You must agree to the Terms of Service.',
],
+8
View File
@@ -782,6 +782,14 @@ return [
'nickname' => [
'max' => '닉네임은 :max자를 초과할 수 없습니다.',
],
'mobile' => [
'max' => '휴대폰번호는 :max자를 초과할 수 없습니다.',
'regex' => '휴대폰번호는 숫자와 -, +, (), 공백만 입력할 수 있습니다.',
],
'phone' => [
'max' => '전화번호는 :max자를 초과할 수 없습니다.',
'regex' => '전화번호는 숫자와 -, +, (), 공백만 입력할 수 있습니다.',
],
'agree_terms' => [
'accepted' => '이용약관에 동의해주세요.',
],
@@ -10,6 +10,7 @@
- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다.
- 게시판 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
### Changed
@@ -0,0 +1,45 @@
// e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 본 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재
/**
* 게시판 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드
*
* @description
* 회귀 시나리오 (코어 admin 템플릿과 동일 구조):
* 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은
* 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송.
* → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는
* `.some(...) ? .map(...) : [...arr, {id...}]` 형태여야 한다.
* 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨.
* 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만
* 노출이어야 하므로 `c.source === 'core'` 분기가 있어야 한다.
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json';
const tabJson = JSON.stringify(tab);
describe('게시판 알림 채널 토글 저장 upsert', () => {
it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => {
// 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가
expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true);
// 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴
expect(
/\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson),
).toBe(true);
});
it('map-only(기존 엔트리만 변형) 저장 표현식이 남아 있지 않다', () => {
expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson), 'upsert(.some) 표현식이 없으면 회귀').toBe(true);
});
});
describe('게시판 하단 서브탭 필터 source 분기', () => {
it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => {
expect(/c\.source === 'core'/.test(tabJson)).toBe(true);
expect(/is_active !== false/.test(tabJson)).toBe(true);
expect(/is_active === true/.test(tabJson)).toBe(true);
});
});
@@ -0,0 +1,78 @@
// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는
// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재
/**
* 게시판 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드
*
* @description
* 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면,
* 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다
* (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림).
*
* extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야
* 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다.
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import tab from '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json';
type AnyNode = Record<string, unknown> & {
id?: string;
type?: string;
name?: string;
props?: { className?: string };
children?: AnyNode[];
};
function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] {
if (!node) return results;
if (pred(node)) results.push(node);
if (Array.isArray(node.children)) {
for (const c of node.children) findAll(c, pred, results);
}
return results;
}
describe('게시판 알림 행 footer extension_point 위치', () => {
it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => {
const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0');
expect(infoColumns.length).toBeGreaterThan(0);
const infoColumnHasEp = infoColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'),
);
expect(infoColumnHasEp).toBe(true);
});
it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => {
const buttonColumns = findAll(
tab as AnyNode,
(n) => n.props?.className === 'flex-center gap-3 flex-shrink-0',
);
expect(buttonColumns.length).toBeGreaterThan(0);
const buttonColumnHasEp = buttonColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point'),
);
expect(buttonColumnHasEp).toBe(false);
});
it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => {
const rowWrappers = findAll(
tab as AnyNode,
(n) =>
Array.isArray(n.children) &&
n.children.some((c) => c.props?.className === 'flex-1 min-w-0') &&
n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'),
);
expect(rowWrappers.length).toBeGreaterThan(0);
for (const wrapper of rowWrappers) {
const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point');
expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false);
}
});
});
@@ -1,6 +1,7 @@
{
"meta": {
"is_partial": true,
"_e2e_allow": "e2e:allow 채널 토글 저장(upsert)·하단탭 필터는 레이아웃 회귀테스트(vitest) + MCP 실브라우저 검증(하단탭 노출/저장/발송)으로 커버. 알림설정 화면 Playwright 인프라 부재",
"description": "게시판 알림 설정 탭 — 채널 토글 + 2단 탭 + 채널별 템플릿 목록"
},
"id": "tab_content_notification_definitions",
@@ -115,7 +116,7 @@
"handler": "setState",
"params": {
"target": "local",
"form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}",
"form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}",
"hasChanges": true
}
}
@@ -139,7 +140,7 @@
"type": "basic",
"name": "Button",
"iteration": {
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}",
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
"item_var": "ch",
"index_var": "chIdx"
},
@@ -195,26 +196,16 @@
]
},
{
"id": "board_notif_list_info",
"id": "board_notif_channel_content",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-between py-3"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}"
},
{
"id": "board_notif_list_info",
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
"className": "flex-between py-3"
},
"children": [
{
@@ -223,55 +214,70 @@
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label"
"text": "$t:sirsoft-board.admin.settings.notification_definitions.total_count|count={{boardNotificationDefinitions?.data?.pagination?.total ?? 0}}"
},
{
"type": "composite",
"name": "Select",
"type": "basic",
"name": "Div",
"props": {
"name": "notifPerPage",
"className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"value": "{{String(query.per_page || 20)}}",
"options": [
{
"value": "10",
"label": "10"
},
{
"value": "20",
"label": "20"
},
{
"value": "50",
"label": "50"
}
]
"className": "flex-center gap-2"
},
"actions": [
"children": [
{
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
"type": "basic",
"name": "Span",
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.per_page_label"
},
{
"type": "composite",
"name": "Select",
"props": {
"name": "notifPerPage",
"className": "w-20 px-3 py-1.5 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm",
"value": "{{String(query.per_page || 20)}}",
"options": [
{
"value": "10",
"label": "10"
},
{
"value": "20",
"label": "20"
},
{
"value": "50",
"label": "50"
}
]
},
"actions": [
{
"type": "change",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"per_page": "{{$event.target.value}}",
"page": 1
}
}
}
}
]
}
]
}
]
}
]
},
{
"id": "board_notif_definitions_list",
"type": "basic",
"name": "Div",
"children": [
},
{
"id": "board_notif_definitions_list",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Div",
@@ -492,6 +498,15 @@
]
}
]
},
{
"comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.",
"type": "extension_point",
"name": "notification_definition_row_footer",
"props": {
"definition": "{{def}}",
"activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}"
}
}
]
},
@@ -656,70 +671,72 @@
}
]
},
{
"id": "board_notif_empty",
"type": "basic",
"name": "Div",
"if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}",
"props": {
"className": "admin-card"
},
"children": [
{
"id": "board_notif_empty",
"type": "basic",
"name": "Div",
"if": "{{(boardNotificationDefinitions?.data?.data ?? []).length === 0}}",
"props": {
"className": "row-stack"
"className": "admin-card"
},
"children": [
{
"type": "basic",
"name": "Icon",
"name": "Div",
"props": {
"name": "fa-solid fa-envelope",
"className": "text-4xl text-gray-300 dark:text-gray-600 mb-3"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle"
"className": "row-stack"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message"
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fa-solid fa-envelope",
"className": "text-4xl text-gray-300 dark:text-gray-600 mb-3"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-label-subtle"
},
"text": "$t:sirsoft-board.admin.settings.notification_definitions.empty_message"
}
]
}
]
}
]
},
{
"id": "board_notif_pagination",
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-center"
},
"children": [
},
{
"type": "composite",
"name": "Pagination",
"id": "board_notif_pagination",
"type": "basic",
"name": "Div",
"props": {
"currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}",
"totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}"
"className": "flex justify-center"
},
"actions": [
"children": [
{
"event": "onPageChange",
"type": "action",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
"type": "composite",
"name": "Pagination",
"props": {
"currentPage": "{{boardNotificationDefinitions?.data?.pagination?.current_page || 1}}",
"totalPages": "{{boardNotificationDefinitions?.data?.pagination?.last_page || 1}}"
},
"actions": [
{
"event": "onPageChange",
"type": "action",
"handler": "navigate",
"params": {
"path": "/admin/boards/settings",
"replace": true,
"mergeQuery": true,
"query": {
"page": "{{$args[0]}}"
}
}
}
}
]
}
]
}
@@ -10,6 +10,7 @@
- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다.
- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다.
- 이커머스 알림 설정 화면에서도 코어와 동일하게 카카오 알림톡 템플릿을 연결할 수 있습니다(비즈뿌리오 플러그인 설치 시).
### Changed
@@ -0,0 +1,44 @@
/**
* 이커머스 알림 채널 토글 저장 upsert + 하단 서브탭 필터 회귀 가드
*
* @description
* 회귀 시나리오 (코어 admin 템플릿과 동일 구조):
* 1) 토글 저장이 `.map()` 만 쓰면(기존 엔트리만 변형) 설정에 없던 확장 채널(sms/alimtalk)은
* 토글해도 배열에 추가되지 않아 저장에서 누락 → 백엔드가 미저장=활성으로 판정해 항상 발송.
* → 저장 표현식은 엔트리 부재 시 새로 추가(upsert)하는
* `.some(...) ? .map(...) : [...arr, {id...}]` 형태여야 한다.
* 2) 하단 서브탭 필터가 `is_active !== false` 만 보면 미저장 확장 채널이 탭에 계속 노출됨.
* 코어 기본 채널(source==='core')은 미저장 시 노출 유지, 확장 채널은 명시적 활성일 때만
* 노출이어야 하므로 `c.source === 'core'` 분기가 있어야 한다.
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json';
const tabJson = JSON.stringify(tab);
describe('이커머스 알림 채널 토글 저장 upsert', () => {
it('토글 저장이 upsert 형태다 (미저장 채널은 새 엔트리로 추가)', () => {
// 엔트리 존재 판별용 .some(...) — 있으면 map 으로 토글, 없으면 spread 로 새 엔트리 추가
expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson)).toBe(true);
// 엔트리 부재 시 새 객체를 배열에 추가하는 spread + 객체 리터럴
expect(
/\[\.\.\.\(_local\.form\?\.notifications\?\.channels \?\? \[\]\), \{id: ch\.id, is_active: true/.test(tabJson),
).toBe(true);
});
it('map-only(기존 엔트리만 변형) 저장 표현식이 남아 있지 않다', () => {
expect(/\.some\(c => c\.id === ch\.id\)/.test(tabJson), 'upsert(.some) 표현식이 없으면 회귀').toBe(true);
});
});
describe('이커머스 하단 서브탭 필터 source 분기', () => {
it('코어기본 채널은 미저장 노출 유지, 확장 채널은 명시적 활성만 노출', () => {
expect(/c\.source === 'core'/.test(tabJson)).toBe(true);
expect(/is_active !== false/.test(tabJson)).toBe(true);
expect(/is_active === true/.test(tabJson)).toBe(true);
});
});
@@ -0,0 +1,78 @@
// e2e:allow 레이아웃 트리 위치 검증(정적 구조 회귀 가드)이라 vitest 로 충분. 실제 화면 렌더는
// Chrome MCP 로 확인·수정까지 완료(2026-07-28). 알림설정 화면 Playwright 인프라 부재
/**
* 이커머스 알림 정의 행 하단 확장 슬롯(notification_definition_row_footer) 위치 회귀 가드
*
* @description
* 회귀 시나리오: extension_point 를 행 정보 컬럼(`flex-1 min-w-0`) 밖에 형제로 잘못 넣으면,
* 비즈뿌리오 플러그인이 주입하는 [연결/변경] 버튼이 정보 컬럼과 토글·편집 버튼 컬럼
* (`flex-center gap-3 flex-shrink-0`) 사이의 3번째 flex 아이템이 되어 옆으로 붙어 보인다
* (변수 뱃지 아래 새 줄이 아니라 오른쪽 버튼 옆으로 밀림).
*
* extension_point 는 반드시 정보 컬럼의 children 배열 마지막 원소(variables 뱃지의 형제)여야
* 하며, 토글/편집 버튼을 담는 형제 Div 의 children 에는 나타나지 않아야 한다.
*
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest';
import tab from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json';
type AnyNode = Record<string, unknown> & {
id?: string;
type?: string;
name?: string;
props?: { className?: string };
children?: AnyNode[];
};
function findAll(node: AnyNode | undefined, pred: (n: AnyNode) => boolean, results: AnyNode[] = []): AnyNode[] {
if (!node) return results;
if (pred(node)) results.push(node);
if (Array.isArray(node.children)) {
for (const c of node.children) findAll(c, pred, results);
}
return results;
}
describe('이커머스 알림 행 footer extension_point 위치', () => {
it('extension_point 는 정보 컬럼(flex-1 min-w-0)의 children 이다', () => {
const infoColumns = findAll(tab as AnyNode, (n) => n.props?.className === 'flex-1 min-w-0');
expect(infoColumns.length).toBeGreaterThan(0);
const infoColumnHasEp = infoColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point' && c.name === 'notification_definition_row_footer'),
);
expect(infoColumnHasEp).toBe(true);
});
it('extension_point 는 토글·편집 버튼 컬럼(flex-center gap-3 flex-shrink-0)의 children 이 아니다', () => {
const buttonColumns = findAll(
tab as AnyNode,
(n) => n.props?.className === 'flex-center gap-3 flex-shrink-0',
);
expect(buttonColumns.length).toBeGreaterThan(0);
const buttonColumnHasEp = buttonColumns.some((col) =>
(col.children ?? []).some((c) => c.type === 'extension_point'),
);
expect(buttonColumnHasEp).toBe(false);
});
it('extension_point 가 정보 컬럼과 버튼 컬럼의 공통 부모(행 wrapper)에 직접 걸려 있지 않다', () => {
const rowWrappers = findAll(
tab as AnyNode,
(n) =>
Array.isArray(n.children) &&
n.children.some((c) => c.props?.className === 'flex-1 min-w-0') &&
n.children.some((c) => c.props?.className === 'flex-center gap-3 flex-shrink-0'),
);
expect(rowWrappers.length).toBeGreaterThan(0);
for (const wrapper of rowWrappers) {
const directEp = (wrapper.children ?? []).some((c) => c.type === 'extension_point');
expect(directEp, 'extension_point 가 행 wrapper 의 3번째 flex 형제가 되면 옆으로 붙는 회귀').toBe(false);
}
});
});
@@ -115,7 +115,7 @@
"handler": "setState",
"params": {
"target": "local",
"form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c)}}",
"form.notifications.channels": "{{(_local.form?.notifications?.channels ?? []).some(c => c.id === ch.id) ? (_local.form?.notifications?.channels ?? []).map(c => c.id === ch.id ? {...c, is_active: !c.is_active} : c) : [...(_local.form?.notifications?.channels ?? []), {id: ch.id, is_active: true, sort_order: (_local.form?.notifications?.channels ?? []).length + 1}]}}",
"hasChanges": true
}
}
@@ -139,7 +139,7 @@
"type": "basic",
"name": "Button",
"iteration": {
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false)}}",
"source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
"item_var": "ch",
"index_var": "chIdx"
},
@@ -498,6 +498,15 @@
]
}
]
},
{
"comment": "알림 정의 행 하단 확장 슬롯(범용). 각 소유 확장이 알림별 부가 UI(예: 채널 연동)를 이 자리에 주입한다. 특정 확장을 알지 못하며, 확장 미설치 시 빈 자리로 남는다. def·현재 채널을 extensionPointProps 로 전달한다.",
"type": "extension_point",
"name": "notification_definition_row_footer",
"props": {
"definition": "{{def}}",
"activeChannel": "{{_local.activeNotificationChannel ?? query.channel ?? 'mail'}}"
}
}
]
},
@@ -0,0 +1,14 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-28
### Added
- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다.
- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다.
- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다.
- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다.
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 비즈뿌리오 메시징 플러그인 (sirsoft-message_bizppurio)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,243 @@
# Bizppurio Messaging Plugin for G7
<p align="center">
<img src="https://img.shields.io/badge/version-1.0.0-blue" alt="Version">
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.3-0066FF" alt="G7">
<img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
</p>
비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다.
G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다.
[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 연동](#알림톡-템플릿-연동) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트)
---
## 주요 기능
- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택
- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영
- 알림톡 미승인/미연결 시 문자로 자동 대체발송(옵션)
- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용)
- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록
- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증
- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용)
- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시
- 알림톡 템플릿 목록·상태·내용 조회 및 알림 연결 (템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행)
- 알림톡 템플릿이 카카오에서 삭제·차단·승인취소된 경우 "사용 불가 — 재연결 필요" 표시
---
## 요구 사항
| 구분 | 항목 | 내용 |
|------|------|------|
| 플랫폼 | G7 | `>= 7.0.3` |
| 플랫폼 | PHP | `^8.2` |
| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 |
| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) |
| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 + 발송할 템플릿의 카카오 검수 승인 |
> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다.
---
## 설치
플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다.
```text
plugins/sirsoft-message_bizppurio
```
프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
```bash
npm install
npm run build
```
그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워집니다.
---
## 관리자 설정
관리자 플러그인 설정 화면에서 비즈뿌리오 계정 정보를 입력합니다.
| 설정 | 필수 여부 | 설명 |
|------|:---:|------|
| 검수 모드 | - | 활성화 시 실제 발송 없이 검수용으로만 동작. 발송 이력에 "검수" 라벨로 표시되어 실제 장애와 구분됨 |
| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 필수 | 비즈뿌리오 계정 로그인 정보 |
| API 키 | 운영 시 필수 | 비즈뿌리오 API 인증에 사용 |
| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 |
| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 |
| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 |
| 알림톡 내용 캐시 시간(분) | 선택 (기본 60) | 카카오 템플릿 내용 재사용 시간. 0이면 매 발송마다 최신 조회, [캐시 초기화] 버튼으로 즉시 반영 가능 |
> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
---
## webhook(발송 결과 리포트) 등록
비즈뿌리오가 발송 결과를 통보할 URL을 비즈뿌리오 콘솔에 등록해야 발송 결과(성공/실패/사유)가 발송 이력에 자동 기록됩니다.
> **이 등록을 하지 않으면 발송 자체는 되지만 성공/실패 여부를 확인할 수 없습니다.**
```text
https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
```
정확한 URL은 관리자 플러그인 설정 화면의 환경설정 탭에서도 복사할 수 있습니다.
---
## 발송 흐름
```text
코어/모듈이 알림 발화 (예: 회원가입, 주문 완료)
→ 알림 설정에 연결된 문자·알림톡 채널로 발송 작업 큐잉
→ 알림톡: 카카오 승인 템플릿 사용, 미승인/미연결 시 문자로 대체발송(옵션)
→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택
→ 비즈뿌리오 발송 API 호출
→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보
→ 발송 이력에 결과 기록, 실패 시 사유·결과코드 함께 기록
```
일시적 오류(카카오 시스템 오류, 처리 지연, 게이트웨이 오류 등)로 실패한 경우 자동으로 재시도합니다. 지갑 잔액 부족·후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리와 함께 관리자에게 알림이 발송됩니다.
---
## 알림톡 템플릿 연동
카카오 알림톡 템플릿의 **등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서** 진행합니다. 이 플러그인의 설정 화면(알림톡 템플릿 탭)은 콘솔에 등록된 템플릿의 목록·상태·내용을 조회하고, 발송가능(승인) 상태의 템플릿을 알림에 연결하는 역할만 담당합니다.
| 상태 | 발송 가능 | 설명 |
|:---:|:---:|------|
| 발송가능 | ✅ | 카카오 검수 승인 완료, 알림에 연결해 발송 가능 |
| 검수중 | ❌ | 카카오 검수 진행 중 |
| 반려 | ❌ | 카카오 검수 반려, 콘솔에서 재신청 필요 |
| 미검수 | ❌ | 콘솔에 등록만 되고 검수 신청 전 상태 |
| 중지 | ❌ | 사용 중지된 템플릿 |
알림에 연결한 템플릿이 이후 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요"가 표시됩니다. 이 경우 알림톡 템플릿 탭에서 다른 승인 템플릿으로 다시 연결해야 합니다.
---
## 발송 결과 코드
비즈뿌리오/카카오가 반환하는 결과 코드는 4가지로 분류되어 처리됩니다.
| 분류 | 처리 방침 |
|:---:|------|
| 성공 | 발송 완료 |
| 재시도 (일시 오류) | 자동 재시도 대상 (예: 카카오 시스템 오류, 처리 지연, 게이트웨이 오류) |
| 잔액 부족 | 즉시 실패 처리 + 관리자 자체 알림 |
| 영구 실패 | 즉시 실패 처리, 재시도하지 않음 |
주요 코드 예시:
| 코드 | 분류 | 사유 |
|:---:|------|------|
| `1000` `4100` `6600` `7000` | 성공 | 발송/리포트 성공 |
| `9070` | 잔액 부족 | 잔액 부족(문자) |
| `9071` | 잔액 부족 | 후불 한도 초과 |
| `7436` | 잔액 부족 | 지갑 잔액 부족(알림톡) |
| `4400` | 영구 실패 | 음영 지역 |
| `7103` | 영구 실패 | 발신 프로필 키 무효 |
발송 이력 화면에는 `사유 (코드)` 형식(예: "음영 지역 (4400)")으로 표시됩니다. 전체 코드 목록은 `lang/ko/result_codes.php` / `lang/en/result_codes.php`에 정의되어 있으며, lang에 없는 코드는 코드만 표시됩니다.
---
## 가용 훅 (Hook)
다른 모듈이나 플러그인에서 아래 훅에 연결해 잔액부족 상황을 확장 처리할 수 있습니다.
### 액션 훅
| 훅 이름 | 시점 | 인수 |
|------|------|------|
| `sirsoft-message_bizppurio.balance.low` | 잔액 부족·한도 초과로 발송 실패 시 (쿨다운 내 최초 1회) | `string $resultCode, string $channel` |
### 훅 등록 예시
```php
use App\Extension\HookManager;
HookManager::addAction(
'sirsoft-message_bizppurio.balance.low',
function (string $resultCode, string $channel) {
// 예: 잔액 부족 시 Slack으로도 별도 알림
SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}");
},
priority: 10
);
```
`$resultCode`는 잔액 부족(`9070` 문자 / `7436` 알림톡) 또는 후불 한도 초과(`9071`) 코드입니다. 이 훅은 관리자 자체 알림(잔액부족/후불한도초과 안내)을 발화하는 지점과 동일하며, 채널별 쿨다운(기본 3600초) 동안 한 번만 실행됩니다.
---
## API
전체 엔드포인트 레퍼런스는 [docs/api/README.md](docs/api/README.md)를 참고하세요.
| 문서 | 내용 |
|------|------|
| [webhook.md](docs/api/webhook.md) | 비즈뿌리오 발송 결과 리포트 수신 |
| [templates.md](docs/api/templates.md) | 알림톡 템플릿 조회 |
| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 템플릿 관리자 API |
| [notification-bindings.md](docs/api/notification-bindings.md) | 알림-템플릿 연결 |
| [dispatch-results.md](docs/api/dispatch-results.md) | 발송 결과 조회 |
| [report.md](docs/api/report.md) | 발송 결과 리포트 URL 조회 |
### 권한
| 권한 | 설명 |
|------|------|
| `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 |
| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 연결, 캐시 초기화 등 관리 작업 |
---
## 삭제 시 동작
플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다.
---
## 보안 및 운영 참고
- 비즈뿌리오 비밀번호, API 키, 알림톡 발신프로필 키는 외부에 노출하지 마세요. 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
- 운영 모드 전환 전 검수 모드에서 문자·알림톡 발송 흐름을 먼저 확인하세요. 운영 모드는 실제 발송과 비용이 발생합니다.
- webhook URL을 비즈뿌리오 콘솔에 등록하지 않으면 발송 결과 확인이 불가능합니다.
- 검수와 운영은 별도의 비즈뿌리오 계정 사용을 권장합니다.
- 지갑 잔액/후불 한도를 주기적으로 확인하세요. 부족 시 관리자 알림이 발송되지만, 알림 자체도 같은 채널(문자/알림톡)을 사용하지 않는 별도 채널(예: 사이트 내 알림, 메일)로 함께 받는 것을 권장합니다.
---
## 테스트
플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
```bash
php artisan test plugins/sirsoft-message_bizppurio/tests
```
프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
```bash
npm install
npm run test:run
npm run build
```
---
## 라이선스
MIT
@@ -0,0 +1,10 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"identifier": "sirsoft-message_bizppurio",
"version": "1.0.0",
"components": {
"basic": [],
"composite": [],
"layout": []
}
}
@@ -0,0 +1,22 @@
{
"name": "plugins/sirsoft-message_bizppurio",
"description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft",
"type": "library",
"version": "1.0.0",
"license": "MIT",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2",
"ext-json": "*"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\MessageBizppurio\\": ["src/", "./"]
}
}
}
@@ -0,0 +1,23 @@
{
"_meta": {
"version": "1.0.0",
"description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false."
},
"defaults": {
"is_test_mode": true,
"bizppurio_id": "",
"password": "",
"api_key": "",
"sender_number": "",
"sender_key": "",
"balance_low_notify_cooldown": 3600
},
"frontend_schema": {
"is_test_mode": { "expose": false },
"bizppurio_id": { "expose": false },
"password": { "expose": false, "sensitive": true },
"api_key": { "expose": false, "sensitive": true },
"sender_number": { "expose": false },
"sender_key": { "expose": false, "sensitive": true }
}
}
@@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 비즈뿌리오 발송 이력 테이블.
*
* 문자(SMS/LMS)·알림톡 1건 발송마다 1행. 발송 시 pending 으로 생성되고,
* 비즈뿌리오 webhook(URL PUSH) 리포트 수신 시 refkey 로 매칭해 상태를 갱신한다.
* 발송 이력 화면(계획서 §6-5)의 데이터소스.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('bizppurio_dispatches');
Schema::create('bizppurio_dispatches', function (Blueprint $table) {
$table->bigIncrements('id')->comment('발송 이력 PK');
$table->string('refkey', 32)->comment('우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)');
$table->string('messagekey', 64)->nullable()->comment('비즈뿌리오 부여 키 (발송 응답 messagekey)');
$table->string('channel', 20)->comment('발송 채널: sms / lms / alimtalk (DispatchChannel enum)');
$table->string('media', 10)->nullable()->comment('webhook MEDIA — 실제 발송 유형: SMS / LMS / KAT(알림톡)');
$table->string('to_number', 20)->comment('수신 전화번호 (숫자만)');
$table->string('to_name', 100)->nullable()->comment('수신자명 (발송 시점 스냅샷)');
$table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null')
->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete();
$table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)');
$table->json('request_payload')->nullable()->comment('실제 비즈뿌리오 API 전송 요청 payload (개인식별 정보 제외 — to/refkey/type/본문 텍스트는 다른 컬럼과 중복이라 제외, account/from/senderkey/templatecode/버튼 등 요소만 저장)');
$table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null');
$table->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다');
$table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)');
$table->string('result_code', 10)->nullable()->comment('결과 코드 (발송응답 or 리포트)');
$table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)');
$table->string('fallback_status', 20)->nullable()->comment('대체발송 결과: 성공/실패/없음 (webhook TELRES)');
$table->string('source', 10)->default('auto')->comment('발송 출처: auto / manual / bulk (DispatchSource enum, 1차 auto)');
$table->boolean('is_test_mode')->nullable()->comment('발송 시점 검수 모드 여부 (null=컬럼 신설 이전 이력)');
$table->timestamp('sent_at')->nullable()->comment('발송 시각');
$table->timestamp('reported_at')->nullable()->comment('webhook 리포트 수신 시각 (replay 멱등 판정)');
$table->json('raw_payload')->nullable()->comment('webhook 원본 페이로드');
$table->timestamps();
$table->unique('refkey', 'bizppurio_dispatch_refkey_unique');
$table->index('channel', 'bizppurio_dispatch_channel_idx');
$table->index('status', 'bizppurio_dispatch_status_idx');
$table->index('notification_type', 'bizppurio_dispatch_notif_type_idx');
$table->index('sent_at', 'bizppurio_dispatch_sent_at_idx');
$table->index('to_user_id', 'bizppurio_dispatch_user_idx');
$table->index(['status', 'sent_at'], 'bizppurio_dispatch_status_sent_idx');
$table->index('notification_log_id', 'bizppurio_dispatch_notif_log_idx');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('bizppurio_dispatches', function (Blueprint $table) {
$table->comment('비즈뿌리오 문자·알림톡 발송 이력');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bizppurio_dispatches');
}
};
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결(설정) 테이블.
*
* "어느 알림에 어느 알림톡 템플릿을 쓸지 + 대체발송 여부"를 저장한다.
* 알림 설정 알림톡 탭 편집 모달(계획서 §6-2)이 우리 API 로 저장한다.
* 변수 매핑 컬럼은 없다 — 변수명 동일 규칙으로 발송 시 자동 치환한다.
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('bizppurio_notification_bindings');
Schema::create('bizppurio_notification_bindings', function (Blueprint $table) {
$table->bigIncrements('id')->comment('연결 설정 PK');
$table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)');
$table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)');
$table->string('template_code', 50)->comment('연결한 카카오 알림톡 템플릿 코드');
$table->string('template_name', 255)->comment('템플릿 이름 (사람 식별 + 고아 감지용 스냅샷)');
$table->boolean('fallback_sms_enabled')->default(false)->comment('개별 대체발송 ON/OFF (실패 시 SMS/LMS 대체)');
$table->boolean('is_active')->default(true)->comment('연동 활성 여부');
$table->timestamps();
$table->unique(['notification_type', 'channel'], 'bizppurio_binding_type_channel_unique');
});
if (DB::getDriverName() === 'mysql') {
Schema::table('bizppurio_notification_bindings', function (Blueprint $table) {
$table->comment('비즈뿌리오 이벤트↔알림톡 템플릿 연결 설정');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('bizppurio_notification_bindings');
}
};
@@ -0,0 +1,2 @@
var SirsoftMessageBizppurio=(function(r){"use strict";const i={},t="sirsoft-message_bizppurio",o=window.G7Core?.createLogger?.(`Plugin:${t}`)??{log:(...e)=>console.log(`[Plugin:${t}]`,...e),warn:(...e)=>console.warn(`[Plugin:${t}]`,...e),error:(...e)=>console.error(`[Plugin:${t}]`,...e)};function a(e){Object.entries(i).forEach(([n,c])=>{e.registerHandler(`${t}.${n}`,c,{category:"plugin",source:t})}),o.log(`${Object.keys(i).length} handler(s) registered:`,Object.keys(i).map(n=>`${t}.${n}`))}function g(e){const n=window.G7Core?.getActionDispatcher?.();if(n){a(n);return}if(!e){o.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let c=0;const l=50,u=()=>{const d=window.G7Core?.getActionDispatcher?.();if(d){a(d);return}++c<=l?setTimeout(u,100):o.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};u()}function s(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>g(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();g(!e)}}return s(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:t,handlers:Object.keys(i),initPlugin:s}),r.initPlugin=s,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}),r})({});
//# sourceMappingURL=plugin.iife.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
# API 레퍼런스 문서 목차
> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반).
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
<!-- @generated:start:api-readme-index -->
- **문서 수**: 6 · **엔드포인트 수**: 12
| 문서 | 도메인 | 엔드포인트 |
| --- | --- | --- |
| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 4 |
| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 |
| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 |
| [report.md](report.md) | `report` | 1 |
| [templates.md](templates.md) | `templates` | 1 |
| [webhook.md](webhook.md) | `webhook` | 1 |
<!-- @generated:end -->
@@ -0,0 +1,224 @@
# Alimtalk Templates API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Alimtalk Templates 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@index`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@categories`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@profiles`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@show`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cache.clear`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@clearCache`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `cleared` | integer | 초기화한 캐시 키 수(연결된 고유 알림톡 템플릿 코드 수) |
**응답 예시**
```json
{
"success": true,
"message": "알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
"data": { "cleared": 3 }
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
**설명**
발송 시 알림톡은 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 카카오 상세조회로 가져와 채우며, 그 결과를 template_code 단위로 캐시한다(기본 1시간, 환경설정 `template_cache_ttl` 로 조정, 0이면 캐시 끔). 카카오 콘솔에서 템플릿 내용을 방금 변경해 캐시 만료를 기다리지 않고 즉시 반영하고 싶을 때 이 엔드포인트로 캐시를 비운다. 연결(binding)된 모든 알림톡 템플릿의 캐시를 초기화하며, 다음 발송에서 최신 내용으로 재조회된다. 카카오 API 를 호출하지 않고 로컬 캐시만 비우므로 rate limit 에 영향을 주지 않는다. 관리자 화면(알림톡 템플릿 탭)의 "내용 캐시 초기화" 버튼이 이 엔드포인트를 호출한다.
@@ -0,0 +1,329 @@
# Dispatch Results API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Dispatch Results 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@lookup`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| notification_log_ids | body | array | 아니오 | max 100 | notification log 식별자 배열 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: application/json
{
"notification_log_ids": [
"예시값"
]
}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| results | array | `[]` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.success",
"data": {
"results": []
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
**설명**
코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(layout_extensions overlay)이 소비하는 조회 API 다.
현재 페이지에 표시된 코어 알림 로그 id 배열(`notification_log_ids`, 최대 100)을 넘기면, 그 로그에
연결된 비즈뿌리오 발송 결과를 **로그 id 를 키로 하는 맵**으로 한 번에 돌려준다(행마다 개별 호출하지
않아 N+1 을 피한다). 코어 알림 로그 테이블은 수정하지 않으며, 연결 표식은 비즈뿌리오 쪽
(`bizppurio_dispatches.notification_log_id`)에만 둔다.
매칭되지 않는 로그 id(메일·사이트내알림 등 비-비즈뿌리오 발송)는 결과 맵에서 제외된다 — 화면에서는
그 행의 결과 컬럼이 빈 셀이 된다. 결과에는 전화번호 등 민감정보를 포함하지 않는다(결과 컬럼은 상태·
사유만 표시).
**응답 필드** (`data.results` — 키는 `notification_log_id`, 값은 결과 객체)
| 필드 | 타입 | 용도 |
| --- | --- | --- |
| status | string\|null | 발송 상태: `sent` / `success` / `failed` (DispatchStatus) |
| status_label | string\|null | 로케일 상태 라벨 (예: "성공", "실패", "발송중") |
| result_code | string\|null | 결과 코드 (발송응답 또는 webhook 리포트). 리포트 미수신 시 null |
| result_label | string\|null | `사유 (코드)` 표시 라벨 (예: "음영 지역 (4400)"). 코드 없으면 null |
| is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 |
| fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null |
| channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` |
| content | string\|null | 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 `notification_logs.body`(대체발송용 코어 템플릿 값)와 달리 카카오 승인 템플릿의 실제 내용이므로, 화면은 이 값을 채널별로 구분해 별도 노출한다 |
**응답 예시**
```json
{
"success": true,
"message": "성공",
"data": {
"results": {
"128": {
"status": "failed",
"status_label": "실패",
"result_code": "4400",
"result_label": "음영 지역 (4400)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
}
}
}
}
```
### GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@recent`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.success",
"data": {
"results": {
"21": {
"status": "pending",
"status_label": "대기",
"result_code": null,
"result_label": null,
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"20": {
"status": "success",
"status_label": "성공",
"result_code": "7000",
"result_label": "성공 (7000)",
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"19": {
"status": "failed",
"status_label": "실패",
"result_code": "7206",
"result_label": "검수되지 않은 템플릿 (7206)",
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"18": {
"status": "failed",
"status_label": "실패",
"result_code": "7436",
"result_label": "지갑 잔액 부족(알림톡) (7436)",
"is_low_balance": true,
"fallback_status": "실패",
"channel": "alimtalk"
},
"17": {
"status": "success",
"status_label": "성공",
"result_code": "7000",
"result_label": "성공 (7000)",
"is_low_balance": false,
"fallback_status": "성공",
"channel": "alimtalk"
},
"16": {
"status": "success",
"status_label": "성공",
"result_code": "7000",
"result_label": "성공 (7000)",
"is_low_balance": false,
"fallback_status": null,
"channel": "alimtalk"
},
"15": {
"status": "failed",
"status_label": "실패",
"result_code": "6603",
"result_label": "음영 지역 (6603)",
"is_low_balance": false,
"fallback_status": null,
"channel": "lms"
},
"14": {
"status": "success",
"status_label": "성공",
"result_code": "6600",
"result_label": "성공 (6600)",
"is_low_balance": false,
"fallback_status": null,
"channel": "lms"
},
"13": {
"status": "failed",
"status_label": "실패",
"result_code": "9999",
"result_label": "9999",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"12": {
"status": "failed",
"status_label": "실패",
"result_code": "9070",
"result_label": "잔액 부족(문자) (9070)",
"is_low_balance": true,
"fallback_status": null,
"channel": "sms"
},
"11": {
"status": "success",
"status_label": "성공",
"result_code": "4100",
"result_label": "성공 (4100)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"10": {
"status": "pending",
"status_label": "대기",
"result_code": null,
"result_label": null,
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"9": {
"status": "sent",
"status_label": "발송중",
"result_code": null,
"result_label": null,
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"8": {
"status": "failed",
"status_label": "실패",
"result_code": "4410",
"result_label": "잘못된 번호 (4410)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"7": {
"status": "failed",
"status_label": "실패",
"result_code": "4400",
"result_label": "음영 지역 (4400)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
},
"6": {
"status": "success",
"status_label": "성공",
"result_code": "4100",
"result_label": "성공 (4100)",
"is_low_balance": false,
"fallback_status": null,
"channel": "sms"
}
}
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
@@ -0,0 +1,196 @@
# Notification Bindings API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Notification Bindings 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@index`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| bindings | array | `[]` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.success",
"data": {
"bindings": []
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명**
알림 설정 화면의 알림톡 탭이 소비하는 목록이다. 코어 알림 정의 중 채널에 `alimtalk` 을 포함하는 활성 알림 전체를, 연결된 알림톡 템플릿(binding)과 조인해 반환한다. 미연결 알림은 `is_bound=false` 이고 `template_code`/`template_name` 은 null, `fallback_sms_enabled` 는 false 다. `variables` 는 코어 알림 정의의 변수 목록으로, 편집 모달의 "제공 변수" 안내에 쓰인다.
### POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@store`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| notification_type | body | string | 예 | max 100 | <!-- TODO: 용도 --> |
| template_code | body | string | 아니오 | max 50 | <!-- TODO: 용도 --> |
| template_name | body | string | 아니오 | max 255 | template 이름 (식별자) |
| fallback_sms_enabled | body | boolean | 아니오 | — | <!-- TODO: 용도 --> |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: application/json
{
"notification_type": "예시값",
"template_code": "예시값",
"template_name": "예시 이름",
"fallback_sms_enabled": true
}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| bindings | object | `{"실측 예시값":{"notification_type":"실측 예시값","template_code":"…` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.binding.saved",
"data": {
"bindings": {
"실측 예시값": {
"notification_type": "실측 예시값",
"template_code": "실측 예시값",
"template_name": "실측 예시값",
"fallback_sms_enabled": true
}
}
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 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)를 서버측에서 재검증하며, 미승인 코드거나 카카오 조회 자체가 실패하면 422 로 거부한다(편집 모달 드롭다운의 승인 템플릿 필터는 화면 단계일 뿐이라, 이를 우회한 직접 API 호출로 미승인 템플릿이 저장되는 것을 막기 위함). 연결 해제(`template_code` 빈 값)는 이 검증을 거치지 않는다.
### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@approvedTemplates`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명**
연동 편집 모달의 "연결 템플릿" 드롭다운을 채우는 소스다. 카카오 템플릿 목록 중 serviceStatus 가 RDY(발송전)·ACT(정상)인 승인 템플릿만 반환한다. 자격증명(bizId·apiKey·senderKey) 미설정이거나 kapi 조회 실패 시 422 로 카카오 사유를 그대로 전달한다.
@@ -0,0 +1,80 @@
# Report API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Report 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### GET /api/plugins/sirsoft-message_bizppurio/admin/report-url
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.report.url -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.report.url`
- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/report-url HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| url | string | `http://g7-issue.eh.test/api/plugins/s…` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"data": {
"url": "https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook"
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 |
<!-- @generated:end -->
**설명**
비즈뿌리오 발송 결과(리포트)를 수신할 콜백 URL을 관리자 설정 페이지에 표시하기 위해 반환하는 엔드포인트입니다. 관리자 환경설정 화면(`admin/plugin_settings.json`)의 `report_url` data_source 가 이 값을 조회해 "리포트 수신 설정" 카드에 readonly 로 표시하고, 운영자가 그대로 복사해 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 URL PUSH 수신 주소로 등록합니다.
URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합합니다 — 리버스 프록시 뒤 PHP-FPM 환경에서 요청 host 가 `localhost` 로 떨어질 수 있어, 운영자가 관리하는 설정값을 신뢰 소스로 삼아 항상 정식 도메인을 노출합니다.
관리자 인증(`auth:sanctum`)과 `core.plugins.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403 으로 응답합니다.
※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다.
@@ -0,0 +1,75 @@
# Templates API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Templates 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.templates.readiness -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.readiness`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| api_key_set | boolean | `false` | <!-- TODO: 설명 --> |
| sender_key_set | boolean | `false` | <!-- TODO: 설명 --> |
| ready | boolean | `false` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"data": {
"api_key_set": "{MASKED}",
"sender_key_set": false,
"ready": false
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
@@ -0,0 +1,86 @@
# Webhook API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Webhook 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### POST /api/plugins/sirsoft-message_bizppurio/webhook
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.webhook -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.webhook`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController@handle`
- **인증/권한**: 공개 (인증 불필요)
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| DEVICE | body | string | 아니오 | max 20 | <!-- TODO: 용도 --> |
| CMSGID | body | string | 아니오 | max 64 | <!-- TODO: 용도 --> |
| MSGID | body | string | 아니오 | max 64 | <!-- TODO: 용도 --> |
| PHONE | body | string | 아니오 | max 20 | <!-- TODO: 용도 --> |
| MEDIA | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
| RESULT | body | string | 예 | max 10 | <!-- TODO: 용도 --> |
| REFKEY | body | string | 예 | max 32 | <!-- TODO: 용도 --> |
| TELRES | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
| KAORES | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/webhook HTTP/1.1
Host: api.example.com
Accept: application/json
Content-Type: application/json
{
"DEVICE": "예시값",
"CMSGID": "예시값",
"MSGID": "예시값",
"PHONE": "010-1234-5678",
"MEDIA": "예시값",
"RESULT": "예시값",
"REFKEY": "예시값",
"TELRES": "예시값",
"KAORES": "예시값"
}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-403 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
**설명**
비즈뿌리오가 문자·알림톡 발송 결과를 URL PUSH 로 통보하는 리포트 수신 엔드포인트다. 운영자가 이 주소를 비즈뿌리오에 등록하면(환경설정 화면의 리포트 수신 주소), 발송 후 결과가 이 엔드포인트로 전송된다.
- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403. IP 화이트리스트는 `plugin.php::getMiddleware()` 에서 이 라우트명(`api.plugins.sirsoft-message_bizppurio.webhook`)으로 self-gate 선언하며, 코어 게이트가 요청 시점에 부착한다(라우트 파일 직접 부착 아님).
- **처리**: `REFKEY` 로 발송 이력을 조회한다. 없으면(위조/미매칭) 200 으로 흡수한다. 이미 리포트가 반영된 이력(`reported_at` 존재)이면 replay 로 판정해 멱등 처리한다. 그 외에는 `RESULT` 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 `media`·`fallback_status`·`raw_payload`·`reported_at` 을 기록한다.
- **잔액부족**: `RESULT` 가 9070(문자)/7436(알림톡)이면 이력을 실패로 뒤집고 관리자에게 자체 알림을 1회 발송한다.
- 응답은 항상 200 이다(비즈뿌리오가 실패 응답을 재전송하지 않도록). replay 멱등이 중복 처리를 막는다.
@@ -0,0 +1,10 @@
<?php
// The alimtalk template screen became read-only, so create/update/inspection/status-change
// activity logs were removed. This plugin no longer writes activity logs (registration and
// management are delegated to the Bizppurio console). Define action/description keys here
// when activity logging is added.
return [
'action' => [],
'description' => [],
];
@@ -0,0 +1,105 @@
<?php
return [
// 발송 채널 (DispatchChannel enum)
'channel' => [
'sms' => 'SMS',
'lms' => 'LMS',
'alimtalk' => 'Alimtalk',
],
// 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용)
'channel_group' => [
'text' => 'SMS',
'alimtalk' => 'Alimtalk',
],
// 발송 상태 (DispatchStatus enum)
'status' => [
'pending' => 'Pending',
'sent' => 'Sending',
'success' => 'Success',
'failed' => 'Failed',
],
// 발송 출처 (DispatchSource enum)
'source' => [
'auto' => 'Automatic',
'manual' => 'Manual',
'bulk' => 'Bulk',
],
// 결과코드 분류 (ResultCategory enum)
'result_category' => [
'success' => 'Success',
'retry' => 'Retry',
'permanent_failure' => 'Permanent Failure',
'balance_low' => 'Insufficient Balance',
],
// 알림 채널 메타 (core.notification.filter_available_channels)
'channels' => [
'source_label' => 'Bizppurio',
'sms' => [
'name' => 'SMS/LMS Text',
'description' => 'Send notifications as SMS/LMS text messages via Bizppurio.',
],
'alimtalk' => [
'name' => 'Kakao Alimtalk',
'description' => 'Send notifications as Kakao Alimtalk messages via Bizppurio.',
],
],
// 채널 준비 상태 사유 (core.notification.channel_readiness)
'readiness' => [
'sms_credentials_missing' => 'Please set the Bizppurio ID and password.',
'sms_sender_number_missing' => 'Please set the sender number.',
'alimtalk_api_key_missing' => 'Please set the Kakao management API key.',
'alimtalk_sender_key_missing' => 'Please set the Alimtalk sender profile key.',
],
// 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용)
'settings' => [
'bizppurio_id_attribute' => 'Bizppurio ID',
'password_attribute' => 'Password',
'sender_number_attribute' => 'Sender Number',
],
// webhook(URL PUSH) 리포트 수신
'webhook' => [
'received' => 'Report received.',
],
// 발송 엔진 오류 (API 클라이언트·토큰·발송 Job)
'error' => [
'credentials_missing' => 'Please set the Bizppurio ID and password first.',
'token_issue_failed' => 'Failed to issue the Bizppurio authentication token.',
'send_failed' => 'Failed to send the message.',
'send_retryable' => 'Message delivery temporarily failed. (code: :code)',
'invalid_response' => 'Unable to parse the Bizppurio response.',
'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)
'send_skipped' => [
'alimtalk_binding_missing' => 'Skipped sending: no alimtalk template is bound. (notification type: :type)',
'alimtalk_kakao_content_unavailable' => 'Skipped sending: failed to fetch the approved Kakao template content. (notification type: :type)',
'sms_template_missing' => 'Skipped sending: no SMS template found. (notification type: :type)',
'recipient_phone_missing' => 'Skipped sending: recipient phone number is missing. (notification type: :type)',
'message_body_empty' => 'Skipped sending: message body is empty. (notification type: :type)',
],
// Notification-to-alimtalk template binding (NotificationBindingController responses)
'binding' => [
'saved' => 'Alimtalk binding saved.',
'removed' => 'Alimtalk binding removed.',
],
// Dispatch template content cache (AlimtalkTemplateController::clearCache response)
'cache' => [
'cleared' => 'Alimtalk template content cache cleared. The latest content will apply from the next dispatch.',
],
];
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| Bizppurio result code → reason (English)
|--------------------------------------------------------------------------
|
| Source: Bizppurio official response-code docs (https://bizppurio.github.io/response-codes/)
| As of: 2026-07-27 (aligned to the latest official docs; legacy manual wording corrected)
| 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).
|
*/
return [
// ── Common / send response ───────────────────────────
'1000' => 'Success',
'2000' => 'Message is invalid',
'3001' => 'Invalid authentication (Basic)',
'3002' => 'Invalid token (expired/revoked)',
'3003' => 'Invalid IP',
'3004' => 'Account is invalid',
'3005' => 'Invalid authentication (Bearer)',
'3006' => 'Account does not exist',
'3007' => 'Invalid account password',
'3009' => 'Account suspended',
'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',
'9000' => 'Temporary system error',
'9070' => 'Insufficient balance (SMS)',
'9071' => 'Postpaid limit exceeded',
// ── SMS report ───────────────────────────────────────
'4100' => 'Success',
'4400' => 'Out of service area',
'4401' => 'Power off',
'4402' => 'Storage full',
'4410' => 'Invalid number',
'4414' => 'Disconnected/suspended number',
'4420' => 'Other device error',
'4430' => 'Spam',
'4431' => 'Delivery-restricted opt-out (spam)',
'4443' => 'Spam blocked',
// ── LMS report ───────────────────────────────────────
'6600' => 'Success',
'6603' => 'Out of service area',
'6604' => 'Power off',
'6606' => 'Invalid number',
'6621' => 'Message length exceeded',
'6641' => 'Spam blocked',
// ── Alimtalk report ──────────────────────────────────
'7000' => 'Success',
'7103' => 'Invalid sender profile key',
'7106' => 'Deleted sender key',
'7107' => 'Blocked sender key',
'7204' => 'Message content does not match template',
'7206' => 'Serial number format mismatch',
'7306' => 'Kakao system error',
'7307' => 'Processing delayed',
'7308' => 'Phone number error',
'7320' => 'Receiver blocked',
'7325' => 'Variable length exceeded',
'7421' => 'Timeout',
'7436' => 'Insufficient wallet balance (Alimtalk)',
'7437' => 'Message request failed',
'7523' => '080 opt-out (spam)',
];
@@ -0,0 +1,9 @@
<?php
// 알림톡 템플릿을 조회 전용으로 전환하면서 등록·수정·검수·상태변경 활동로그를 제거했다.
// 현재 이 플러그인은 활동로그를 기록하지 않는다(등록·관리는 비즈뿌리오 콘솔로 위임).
// 활동로그 기록을 추가할 때 action/description 키를 여기에 정의한다.
return [
'action' => [],
'description' => [],
];
@@ -0,0 +1,105 @@
<?php
return [
// 발송 채널 (DispatchChannel enum)
'channel' => [
'sms' => 'SMS',
'lms' => 'LMS',
'alimtalk' => '알림톡',
],
// 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용)
'channel_group' => [
'text' => '문자',
'alimtalk' => '알림톡',
],
// 발송 상태 (DispatchStatus enum)
'status' => [
'pending' => '대기',
'sent' => '발송중',
'success' => '성공',
'failed' => '실패',
],
// 발송 출처 (DispatchSource enum)
'source' => [
'auto' => '자동',
'manual' => '수동',
'bulk' => '대량',
],
// 결과코드 분류 (ResultCategory enum)
'result_category' => [
'success' => '성공',
'retry' => '재시도',
'permanent_failure' => '영구 실패',
'balance_low' => '잔액 부족',
],
// 알림 채널 메타 (core.notification.filter_available_channels)
'channels' => [
'source_label' => '비즈뿌리오',
'sms' => [
'name' => 'SMS/LMS 문자',
'description' => '비즈뿌리오를 통해 문자(SMS/LMS)로 알림을 발송합니다.',
],
'alimtalk' => [
'name' => '카카오 알림톡',
'description' => '비즈뿌리오를 통해 카카오 알림톡으로 알림을 발송합니다.',
],
],
// 채널 준비 상태 사유 (core.notification.channel_readiness)
'readiness' => [
'sms_credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 설정하세요.',
'sms_sender_number_missing' => '발신번호를 설정하세요.',
'alimtalk_api_key_missing' => '카카오 관리 API 키를 설정하세요.',
'alimtalk_sender_key_missing' => '알림톡 발신프로필 키를 설정하세요.',
],
// 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용)
'settings' => [
'bizppurio_id_attribute' => '비즈뿌리오 아이디',
'password_attribute' => '비밀번호',
'sender_number_attribute' => '발신번호',
],
// webhook(URL PUSH) 리포트 수신
'webhook' => [
'received' => '리포트를 수신했습니다.',
],
// 발송 엔진 오류 (API 클라이언트·토큰·발송 Job)
'error' => [
'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.',
'token_issue_failed' => '비즈뿌리오 인증 토큰 발급에 실패했습니다.',
'send_failed' => '메시지 발송 요청에 실패했습니다.',
'send_retryable' => '메시지 발송이 일시적으로 실패했습니다. (코드: :code)',
'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.',
'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.',
'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.',
'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.',
'template_not_sendable' => '발송 가능(승인) 상태가 아닌 템플릿입니다. (코드: :code)',
],
// 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨)
'send_skipped' => [
'alimtalk_binding_missing' => '알림톡 템플릿이 연결되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)',
'alimtalk_kakao_content_unavailable' => '카카오 승인 템플릿 내용을 조회하지 못해 발송을 건너뛰었습니다. (알림 유형: :type)',
'sms_template_missing' => 'SMS 템플릿이 없어 발송을 건너뛰었습니다. (알림 유형: :type)',
'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)',
'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)',
],
// 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답)
'binding' => [
'saved' => '알림톡 연동을 저장했습니다.',
'removed' => '알림톡 연동을 해제했습니다.',
],
// 발송용 템플릿 내용 캐시 (AlimtalkTemplateController::clearCache 응답)
'cache' => [
'cleared' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.',
],
];
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/*
|--------------------------------------------------------------------------
| 비즈뿌리오 결과 코드 → 사유 (한국어)
|--------------------------------------------------------------------------
|
| 출처: 비즈뿌리오 공식 응답 코드 문서 (https://bizppurio.github.io/response-codes/)
| 기준일: 2026-07-27 (최신 공식 문서 기준으로 정합화 — 구 매뉴얼 표기 정정)
| 범위: 공통(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523)
| RCS(8000대)·네이버톡톡(5000대 일부)은 범위 밖(후속 D15).
|
| 매뉴얼이 삭제되어도 이 파일이 원본 스펙을 보존한다. 새 코드 추가 시 분류는
| ResultCodeResolver 의 상수 배열(SUCCESS/RETRYABLE/BALANCE_LOW)과 함께 갱신한다.
|
*/
return [
// ── 공통 / 발송 응답 ──────────────────────────────────
'1000' => '성공',
'2000' => '메시지가 유효하지 않음',
'3001' => '인증 정보 무효(Basic)',
'3002' => '토큰 무효(만료·폐기)',
'3003' => 'IP 무효',
'3004' => '계정이 유효하지 않음',
'3005' => '인증 정보 무효(Bearer)',
'3006' => '계정이 존재하지 않음',
'3007' => '계정 암호 무효',
'3009' => '계정 중지 상태',
'3010' => '접속 허용 IP 불일치',
'3011' => '알 수 없는 오류(비즈뿌리오)',
'3013' => '완료 처리되지 않은 메시지',
'5002' => '요청 과다',
'5003' => '일시적 전송 오류',
'5004' => '일시적 전송 오류',
'5005' => '일시적 전송 오류',
'9000' => '일시적 시스템 오류',
'9070' => '잔액 부족(문자)',
'9071' => '후불 한도 초과',
// ── SMS 리포트 ────────────────────────────────────────
'4100' => '성공',
'4400' => '음영 지역',
'4401' => '전원 꺼짐',
'4402' => '저장 매체 초과',
'4410' => '잘못된 번호',
'4414' => '결번·정지',
'4420' => '기타 단말 오류',
'4430' => '스팸',
'4431' => '발송 제한 수신거부(스팸)',
'4443' => '스팸 차단',
// ── LMS 리포트 ────────────────────────────────────────
'6600' => '성공',
'6603' => '음영 지역',
'6604' => '전원 꺼짐',
'6606' => '잘못된 번호',
'6621' => '메시지 길이 초과',
'6641' => '스팸 차단',
// ── 알림톡 리포트 ─────────────────────────────────────
'7000' => '성공',
'7103' => '발신 프로필 키 무효',
'7106' => '삭제된 발신 키',
'7107' => '차단된 발신 키',
'7204' => '메시지 내용이 템플릿과 불일치',
'7206' => '시리얼넘버 형식 불일치',
'7306' => '카카오 시스템 오류',
'7307' => '처리 지연',
'7308' => '전화번호 오류',
'7320' => '수신 차단',
'7325' => '변수 길이 초과',
'7421' => '타임아웃',
'7436' => '지갑 잔액 부족(알림톡)',
'7437' => '메시지 요청 실패',
'7523' => '080 수신거부(스팸)',
];
@@ -0,0 +1,20 @@
{
"name": "@plugins/sirsoft-message_bizppurio",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"@types/node": "^22.0.0",
"jsdom": "^25.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.14",
"vitest": "^2.1.0"
}
}
@@ -0,0 +1,32 @@
{
"identifier": "sirsoft-message_bizppurio",
"vendor": "sirsoft",
"name": {
"ko": "비즈뿌리오 메시지 발송",
"en": "Bizppurio Messaging"
},
"version": "1.0.0",
"description": {
"ko": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.",
"en": "Bizppurio SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook."
},
"license": "MIT",
"g7_version": ">=7.0.3",
"dependencies": {
"modules": {},
"plugins": {}
},
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio/blob/main/CHANGELOG.md",
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/plugin.iife.js"
},
"handlers": true
},
"loading": {
"strategy": "global",
"priority": 100
}
}
@@ -0,0 +1,478 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio;
use App\Enums\ExtensionOwnerType;
use App\Extension\AbstractPlugin;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\HookListenerRegistrar;
use App\Extension\ModuleManager;
use App\Models\NotificationDefinition;
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;
use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener;
use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener;
/**
* 비즈뿌리오 메시지 발송 플러그인
*
* 비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송을 제공합니다.
* 코어 알림 시스템의 채널로 문자·알림톡을 발송하고, 발송 결과를 webhook 으로
* 수신하여 이력에 기록합니다.
*/
class Plugin extends AbstractPlugin
{
/**
* 플러그인 메타데이터 반환
*
* @return array 메타데이터
*/
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['bizppurio', 'sms', 'lms', 'alimtalk', 'kakao', 'messaging', 'notification'],
];
}
/**
* 플러그인 활성화 — 기존 회원 알림에 sms·alimtalk template 을 즉시 증강.
*
* SeedChannelTemplatesListener 는 코어/모듈이 알림 정의를 *시딩할 때* 필터 훅으로 증강한다.
* 그러나 플러그인 활성화 시점에는 코어/모듈 정의가 이미 시딩돼 있어(우리 채널 없이) 다음
* 재시딩까지 alimtalk template 이 생기지 않는다. 따라서 활성화 시 코어·활성 모듈의 알림
* 정의를 재시드해, 이제 활성화된 우리 필터를 통과시켜 기존 정의에도 채널을 즉시 반영한다.
*
* 재시드는 모두 user_overrides 보존 upsert(멱등)이며 코어·모듈 파일을 수정하지 않는다.
* 실패는 로그만 남기고 활성화 자체는 막지 않는다(발송·연동은 채널 등록만으로도 동작하며,
* template 은 다음 정상 재시딩에도 수렴).
*
* 리스너 선등록: PluginManager::activatePlugin() 은 DB status 를 active 로 바꾸기
* '전에' 이 activate() 를 호출한다. 그런데 registerPluginHookListeners() 의 active 가드는
* status=active 인 플러그인의 리스너만 등록하므로, 이 시점엔 SeedChannelTemplatesListener 가
* 아직 등록되지 않았다. 그대로 재시딩하면 시딩 필터에 우리 리스너가 편승하지 못해
* sms·alimtalk 채널 template 이 붙지 않는다(실서버 회귀). 따라서 재시딩 전에 이 리스너를
* 명시적으로 선등록한다. register() 는 동일 process 내 중복 등록을 막으므로 멱등하다.
*
* @return bool 활성화 성공 여부
*/
public function activate(): bool
{
try {
// 재시딩이 편승할 시딩 필터 리스너를 status=active 전환 이전에 명시적으로 등록한다.
HookListenerRegistrar::register(SeedChannelTemplatesListener::class, $this->getIdentifier());
app(NotificationDefinitionSeeder::class)->run();
app(ModuleManager::class)->resyncAllActiveDeclarativeArtifacts();
} catch (\Throwable $e) {
Log::warning('[sirsoft-message_bizppurio] 알림 채널 template 증강 재시드 실패', [
'error' => $e->getMessage(),
]);
}
return true;
}
/**
* 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리.
*
* 이 플러그인은 관리자 메뉴를 만들지 않는다(화면 배치 결정 2026-07-14 — 진입은 코어
* 소유 설정 페이지 하나로 통일). 다만 이전 버전(getAdminMenus 사용 시기)에 생성된
* 메뉴 row 가 DB 에 남아 있을 수 있어, 비활성화 시 currentSlugs=[] 로 cleanupStaleMenus
* 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로
* 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리.
*
* SeedChannelTemplatesListener 의 HookListenerRegistrar 등록 이력 캐시를 함께 지운다 —
* activate() 가 register() 로 등록한 뒤(process-wide idempotency 캐시, source::class 키),
* deactivate/uninstall 후 재활성화해도 이 캐시가 "이미 등록됨"으로 남아있으면 재등록이
* 조용히 skip 되어 재시딩 시 sms·alimtalk 채널이 다시 붙지 않는다(회귀). 코어
* HookListenerRegistrar 에는 특정 키 1건만 지우는 API 가 없고(clear() 는 전체 초기화라
* 다른 확장의 등록 상태까지 날아감), 코어 수정 없이 이 플러그인 내부에서만 해결하기
* 위해 리플렉션으로 private static $registered 캐시에서 이 키만 직접 제거한다.
*
* @return bool 비활성화 성공 여부
*/
public function deactivate(): bool
{
app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus(
ExtensionOwnerType::Plugin,
$this->getIdentifier(),
currentSlugs: [],
);
$this->forgetSeedChannelTemplatesListenerRegistration();
return true;
}
/**
* HookListenerRegistrar::$registered 캐시에서 SeedChannelTemplatesListener 등록 이력만 제거합니다.
*
* 코어 HookListenerRegistrar 는 개별 키 삭제 API 를 제공하지 않으므로(clear() 는 전체
* 초기화), 코어를 수정하지 않고 이 캐시를 조작하기 위해 리플렉션을 사용한다. 실패해도
* (리플렉션 예외 등) 치명적이지 않으므로 조용히 무시한다 — 최악의 경우 이번 재활성화만
* 채널 재시딩이 안 붙고, 운영자가 다시 활성화하면 정상화된다.
*/
private function forgetSeedChannelTemplatesListenerRegistration(): void
{
try {
$ref = new \ReflectionClass(HookListenerRegistrar::class);
$prop = $ref->getProperty('registered');
$registered = $prop->getValue();
$key = $this->getIdentifier().'::'.SeedChannelTemplatesListener::class;
unset($registered[$key]);
$prop->setValue(null, $registered);
} catch (\Throwable $e) {
Log::warning('[sirsoft-message_bizppurio] SeedChannelTemplatesListener 등록 캐시 초기화 실패', [
'error' => $e->getMessage(),
]);
}
}
/**
* 플러그인 제거 — 메뉴 잔재 안전망(정상 흐름은 deactivate 가 먼저 처리) + sms·alimtalk 채널 잔재 정리.
*
* SeedChannelTemplatesListener 가 코어/게시판/이커머스 소유 알림 정의에 필터 훅으로 끼워넣은
* sms·alimtalk template 은, 그 정의가 우리 소유가 아니므로 PluginManager 의 범용 알림 정의
* 정리(cleanupStaleDefinitions('plugin', ...))에 걸리지 않는다. 코어는 이 채널의 존재나
* 소유자를 몰라야 하므로, 코어를 수정하지 않고 우리가 직접 우리 채널 상수 기준으로 정리한다.
*
* "데이터도 함께 삭제" 옵션과 무관하게 항상 정리한다 — sms·alimtalk 은 이 플러그인 없이는
* 발송이 불가능한 죽은 설정이라 보존할 가치가 없고, 재설치 시 activate() 가 재시딩하며
* 자동으로 복원된다.
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool
{
$this->deactivate();
$this->cleanupChannelContributions();
return true;
}
/**
* sms·alimtalk 채널 template 및 definitions.channels 배열에서 우리 채널 잔재를 정리합니다.
*
* 삭제 대상 채널은 우리가 이미 아는 채널 상수(RegisterNotificationChannelsListener::CHANNEL_IDS)
* 이므로, 각 정의가 원래 어떤 채널로 구성돼 있었는지 사전 지식 없이 "현재 DB 채널 목록 −
* 우리 채널" 만으로 남길 목록을 계산할 수 있다. template 삭제는 코어 공개 메서드
* NotificationSyncHelper::cleanupStaleTemplates() 를 그대로 사용해 로깅·모델 이벤트 등
* 코어 도메인 규칙을 그대로 따른다.
*/
private function cleanupChannelContributions(): void
{
$myChannels = RegisterNotificationChannelsListener::CHANNEL_IDS;
$helper = app(NotificationSyncHelper::class);
$definitionIds = DB::table('notification_templates')
->whereIn('channel', $myChannels)
->distinct()
->pluck('definition_id');
foreach ($definitionIds as $definitionId) {
$current = DB::table('notification_templates')
->where('definition_id', $definitionId)
->pluck('channel')
->all();
$keep = array_values(array_diff($current, $myChannels));
$helper->cleanupStaleTemplates($definitionId, $keep);
$definition = NotificationDefinition::find($definitionId);
if ($definition) {
$definition->channels = $keep;
$definition->save();
}
}
}
/**
* 플러그인 권한 목록 반환 (계층 구조)
*
* PluginManager 가 1레벨(플러그인 노드) → 2레벨(카테고리) → 3레벨(개별 권한) 트리로 등록.
* 모든 권한은 admin 역할에 매핑.
*
* 권한 분할 의도:
* - view: 발송 이력·알림톡 템플릿 조회 (모니터링)
* - manage: 환경설정·템플릿 등록/검수·이벤트 연동 (운영)
*
* @return array 권한 정의 배열 (categories 계층 구조)
*/
public function getPermissions(): array
{
return [
'name' => [
'ko' => '비즈뿌리오 메시지 발송',
'en' => 'Bizppurio Messaging',
],
'description' => [
'ko' => '비즈뿌리오 메시지 발송 플러그인이 제공하는 권한',
'en' => 'Permissions provided by the Bizppurio Messaging plugin',
],
'categories' => [
[
'identifier' => 'messaging',
'name' => ['ko' => '메시지 발송', 'en' => 'Messaging'],
'description' => [
'ko' => '메시지 발송 도메인 권한 (조회·관리)',
'en' => 'Messaging domain permissions (view, manage)',
],
'permissions' => [
[
'action' => 'view',
'name' => ['ko' => '메시지 조회', 'en' => 'View Messaging'],
'description' => [
'ko' => '발송 이력·알림톡 템플릿 조회 (모니터링)',
'en' => 'View dispatch history and alimtalk templates (monitoring)',
],
'type' => 'admin',
'roles' => ['admin'],
],
[
'action' => 'manage',
'name' => ['ko' => '메시지 관리', 'en' => 'Manage Messaging'],
'description' => [
'ko' => '환경설정·알림톡 템플릿 등록/검수·이벤트 연동 관리',
'en' => 'Manage settings, alimtalk template registration/inspection, and event bindings',
],
'type' => 'admin',
'roles' => ['admin'],
],
],
],
],
];
}
/**
* 플러그인 설정 스키마 반환
*
* 관리자 설정 페이지 UI 를 동적으로 생성하는 데 사용됩니다. 크리덴셜(비밀번호·API 키)은
* sensitive 로 마킹하여 마스킹하며, frontend_schema(defaults.json)에서 expose:false 로
* 프론트 노출을 차단합니다.
*
* 발송 시스템(account)과 카카오 관리 시스템(bizId)의 식별자는 동일한 '비즈뿌리오 아이디'
* 이므로 bizppurio_id 단일 필드로 받는다.
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
'is_test_mode' => [
'type' => 'boolean',
'default' => true,
'label' => ['ko' => '검수 모드', 'en' => 'Test Mode'],
'hint' => [
'ko' => '검수 모드를 끄면 운영 환경으로 발송됩니다. 발송 API 도메인이 환경에 따라 분기됩니다.',
'en' => 'Turn off test mode to send in the production environment. The sending API domain differs by environment.',
],
'required' => false,
],
'bizppurio_id' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '비즈뿌리오 아이디', 'en' => 'Bizppurio ID'],
'hint' => [
'ko' => '발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다.',
'en' => 'The Bizppurio account ID used for both sending and Kakao management.',
],
'required' => false,
],
'password' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '비밀번호', 'en' => 'Password'],
'hint' => [
'ko' => '발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다.',
'en' => 'The Bizppurio password used to issue the sending token.',
],
'sensitive' => true,
'required' => false,
],
'api_key' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => 'API 키', 'en' => 'API Key'],
'hint' => [
'ko' => '카카오 관리(알림톡 템플릿·발신프로필)에 사용하는 API 키입니다. 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다.',
'en' => 'The API key used for Kakao management (alimtalk templates, sender profiles).',
],
'sensitive' => true,
'required' => false,
],
'sender_number' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '발신번호', 'en' => 'Sender Number'],
'hint' => [
'ko' => '문자·알림톡 발송에 사용하는 발신 전화번호입니다.',
'en' => 'The sender phone number used for SMS and alimtalk delivery.',
],
'required' => false,
],
'sender_key' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '알림톡 발신프로필 키', 'en' => 'Alimtalk Sender Profile Key'],
'hint' => [
'ko' => '알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다.',
'en' => 'The 40-character sender profile key used for alimtalk delivery and template lookup.',
],
'sensitive' => true,
'required' => false,
],
'template_cache_minutes' => [
'type' => 'number',
'default' => 60,
'label' => ['ko' => '알림톡 내용 캐시 시간(분)', 'en' => 'Alimtalk content cache (minutes)'],
'hint' => [
'ko' => '카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용합니다(기본 60분). 이 시간이 지나면 다음 발송 때 최신 내용을 다시 가져옵니다. 0으로 두면 매번 최신 내용을 가져옵니다 — 발송이 많으면 조회 제한에 걸릴 수 있어 권장하지 않습니다. 카카오에서 템플릿을 방금 수정했다면 아래 [캐시 초기화]로 즉시 반영할 수 있습니다.',
'en' => 'Reuses Kakao alimtalk template content for this period (default 60 minutes). After it expires, the latest content is fetched on the next dispatch. Set to 0 to always fetch the latest — not recommended for high volume as it may hit rate limits. If you just edited a template in Kakao, use [Clear cache] below to apply it immediately.',
],
'required' => false,
],
];
}
/**
* 플러그인 설정 기본값 반환 (하위 호환)
*
* 신규 설치 기본값은 config/settings/defaults.json 의 defaults 섹션이 1순위이며,
* 본 메서드는 하위 호환 경로로 동일 값을 반환한다.
*
* @return array 기본 설정값
*/
public function getConfigValues(): array
{
return [
'is_test_mode' => true,
'bizppurio_id' => '',
'password' => '',
'api_key' => '',
'sender_number' => '',
'sender_key' => '',
'template_cache_minutes' => 60,
];
}
/**
* 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다.
*
* Phase 1 범위: 비즈뿌리오 지갑 잔액부족 시 관리자에게 보내는 자체 알림 1건.
* (webhook 결과코드 9070/7436 감지 시 Phase 4 에서 이 알림을 발화)
*
* ※ 3영역(코어/게시판/이커머스) 알림톡 채널 기본 body 시드는 알림톡 채널 등록(Phase 3)·
* 탭 연동(Phase 6)과 강결합이므로 Phase 6 으로 이관한다. 그때 본 메서드에 정의를 추가한다.
*
* @return array<int, array<string, mixed>>
*/
public function getNotificationDefinitions(): array
{
return [
[
'type' => 'bizppurio_balance_low',
'hook_prefix' => 'sirsoft-message_bizppurio',
'name' => [
'ko' => '비즈뿌리오 잔액 부족',
'en' => 'Bizppurio Balance Low',
],
'description' => [
'ko' => '비즈뿌리오 지갑 잔액이 부족해 문자/알림톡 발송이 실패했을 때 관리자에게 발송',
'en' => 'Sent to admin when a message fails due to insufficient Bizppurio wallet balance',
],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-message_bizppurio.balance.low'],
'variables' => [
['key' => 'name', 'description' => '수신자(관리자) 이름'],
['key' => 'app_name', 'description' => '사이트 이름'],
['key' => 'result_code', 'description' => '결과 코드(9070 문자 / 7436 알림톡)'],
['key' => 'channel_label', 'description' => '발송 채널(문자/알림톡)'],
['key' => 'settings_url', 'description' => '메시징 환경설정 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'role', 'value' => 'admin']],
'subject' => [
'ko' => '[{app_name}] 비즈뿌리오 잔액이 부족합니다',
'en' => '[{app_name}] Bizppurio balance is insufficient',
],
'body' => [
'ko' => '{name}님, 비즈뿌리오 지갑 잔액이 부족하여 {channel_label} 발송이 실패했습니다 (코드: {result_code}). 충전 후 발송이 정상화됩니다.',
'en' => 'Dear {name}, a {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}). Delivery resumes after recharging.',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'role', 'value' => 'admin']],
'subject' => [
'ko' => '비즈뿌리오 잔액 부족',
'en' => 'Bizppurio balance low',
],
'body' => [
'ko' => '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).',
'en' => 'A {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}).',
],
],
],
],
];
}
/**
* 훅 리스너 목록 반환
*
* - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3)
* - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅)
* - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3)
* - LinkNotificationLogListener: 코어 알림 로그↔dispatch 연결(A-2 — 발송 이력 결과 주입 연결고리)
* - ValidateBizppurioSettingsListener: 환경설정 검증
*
* @return array<class-string>
*/
public function getHookListeners(): array
{
return [
RegisterNotificationChannelsListener::class,
SeedChannelTemplatesListener::class,
GuestPhoneExtractListener::class,
BalanceLowNotificationDataListener::class,
LinkNotificationLogListener::class,
ValidateBizppurioSettingsListener::class,
];
}
/**
* 확장 미들웨어 선언 (self-gate)
*
* webhook(URL PUSH) 리포트 수신 엔드포인트에만 IP 화이트리스트를 부착한다.
* 라우트 파일에서 직접 부착하지 않고 코어 게이트(ExtensionMiddlewareGate)가
* 요청 시점에 라우트 이름을 대조해 매칭될 때만 실행한다.
*
* @return array<int, array<string, mixed>>
*/
public function getMiddleware(): array
{
return [
[
'class' => BizppurioWebhookIpWhitelist::class,
'groups' => ['api'],
'targets' => [
'api.plugins.sirsoft-message_bizppurio.webhook',
],
],
];
}
}
@@ -0,0 +1,210 @@
{
"target_layout": "admin_notification_log_list",
"comment": "코어 알림 발송 이력 화면에 비즈뿌리오 발송 결과 컬럼을 얹는다(A-2). 코어 화면·코어 앱·코어 테이블은 전혀 건드리지 않는다 — 연결 표식은 우리 dispatch(bizppurio_dispatches.notification_log_id)에만 있고, 현재 페이지의 코어 로그 id 배열로 결과를 배치 조회(dispatchResults)해 row.id 로 매칭한다. 매칭 안 되는 행(메일·DB 등 비-비즈뿌리오)은 빈 셀. 컬럼은 코어 datagrid columns 배열 끝에 _append 로 1개만 추가(전체 컬럼 재정의 안 함).",
"data_sources": [
{
"id": "dispatchResults",
"label_key": "$t:sirsoft-message_bizppurio.editor.data_source.dispatch_results",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"loading_strategy": "progressive",
"fallback": {
"data": {
"results": {}
}
}
}
],
"injections": [
{
"target_id": "notification_log_datagrid",
"position": "inject_props",
"comment": "코어 datagrid columns 끝에 비즈뿌리오 결과 컬럼 1개만 추가. columns 는 정적 배열이라 _append 적용 가능.",
"props": {
"expandChildren": {
"_append": [
{
"comment": "행 토글(펼침) 안에도 비즈뿌리오 결과를 넣는다. 비즈뿌리오 발송 행(결과 매칭됨)일 때만 노출 — 메일·사이트내알림 등 매칭 안 되는 행에선 아무것도 안 보인다.",
"type": "basic",
"name": "Div",
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}",
"props": {
"className": "p-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 space-y-2"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-comment-dots",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm font-medium text-gray-800 dark:text-gray-100"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.detail_title"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap items-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}"
},
"text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}"
}
]
},
{
"comment": "알림톡 실제 발송 내용 — 코어 '본문'(notification_logs.body)은 SMS 대체발송용 코어 템플릿 값이라 실제 카카오 발송 내용(승인 템플릿 전체)과 다르다. row.channel==='alimtalk'이고 실제 발송 내용이 있을 때만 별도 노출해 혼동을 방지한다.",
"type": "basic",
"name": "Div",
"if": "{{row.channel === 'alimtalk' && !!(dispatchResults?.data?.results ?? {})[row.id]?.content}}",
"props": {
"className": "pt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_label"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-line admin-card"
},
"text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.content}}"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_hint"
}
]
}
]
}
]
},
"columns": {
"_append": [
{
"field": "bizppurio_result",
"header": "$t:sirsoft-message_bizppurio.dispatch_result.column_header",
"width": "200px",
"sortable": false,
"required": false,
"comment": "컬럼(헤더 포함) 자체를 탭별로 노출 제어. 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로(셀과 달리) query 접근 가능. 메일·사이트내알림(mail/database) 탭이면 hidden=true 로 컬럼 숨김. 전체·sms·lms·알림톡 탭에선 표시.",
"hidden": "{{['mail','database'].includes(query.channel ?? '')}}",
"cellChildren": [
{
"comment": "비즈뿌리오 발송 행(row.channel 이 sms/lms/alimtalk)에서만 결과 표시. 메일·사이트내알림(mail/database) 행은 셀을 비운다. 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없으므로(코어 DataGrid renderCellChildren 계약) 탭이 아니라 row.channel 로 판별한다.",
"type": "basic",
"name": "Div",
"if": "{{['sms','lms','alimtalk'].includes(row.channel)}}",
"children": [
{
"type": "basic",
"name": "Span",
"if": "{{!(dispatchResults?.data?.results ?? {})[row.id]}}",
"props": {
"className": "text-gray-300 dark:text-gray-600"
},
"text": "-"
},
{
"type": "basic",
"name": "Div",
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}",
"props": {
"className": "flex flex-col gap-1 items-start"
},
"children": [
{
"type": "basic",
"name": "Span",
"props": {
"className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}"
},
"text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}"
},
{
"type": "basic",
"name": "Span",
"if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance"
},
{
"type": "basic",
"name": "Span",
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}",
"props": {
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100"
},
"text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}"
}
]
}
]
}
]
}
]
}
}
}
],
"priority": 330
}
@@ -0,0 +1,139 @@
{
"extension_point": "notification_definition_row_footer",
"comment": "알림 설정 알림톡 탭의 각 알림 행 하단(코어 notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼을 채운다(계획서 §6-2, Phase 6 재설계). channel==='alimtalk'일 때만 노출. extensionPointProps.definition 으로 이 행의 알림(def)을 받고, 연결 정보는 bizppurioBindings(overlay notification_tab_core.json 이 등록·조회) 에서 def.type 으로 읽는다. [연결/변경] → 우리 연결 모달(modal_bizppurio_binding, overlay 가 등록)을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.",
"components": [
{
"id": "bizppurio_row_binding",
"type": "basic",
"name": "Div",
"if": "{{extensionPointProps.activeChannel === 'alimtalk'}}",
"props": {
"className": "flex-between gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2 min-w-0"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-link",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"comment": "연결됨: 템플릿명(또는 코드)",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}",
"props": {
"className": "text-sm text-gray-700 dark:text-gray-200 truncate"
},
"text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}"
},
{
"comment": "소실 경고 — 연결됨이지만 연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가(is_unavailable). 승인 목록 대조가 가능했고(=is_unavailable 필드 존재) true 일 때만 표시. 카카오 조회 실패 시 서비스가 필드를 부여하지 않아 배지도 뜨지 않는다(오탐 방지).",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.is_unavailable === true}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 flex-shrink-0"
},
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "fas fa-triangle-exclamation", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.unavailable" }
]
},
{
"comment": "⑧ SMS 대체발송 배지 — 연결됨일 때만, 켜짐/꺼짐을 색으로 구분해 둘 다 표시(다크에서 묽지 않게 solid 색)",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-200 flex-shrink-0"
},
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_on" }
]
},
{
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && !(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0"
},
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_off" }
]
},
{
"type": "basic",
"name": "Span",
"if": "{{!(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code)}}",
"props": {
"className": "text-sm text-gray-400 dark:text-gray-500"
},
"text": "$t:sirsoft-message_bizppurio.binding.unbound"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 whitespace-nowrap flex-shrink-0"
},
"text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ? '$t:sirsoft-message_bizppurio.binding.btn_change' : '$t:sirsoft-message_bizppurio.binding.btn_connect'}}",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"comment": "모달 상태 seed — 대상 알림 type·이름·변수 + 기존 연결값 프리필",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal": {
"notification_type": "{{extensionPointProps.definition?.type}}",
"notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
"variables": "{{extensionPointProps.definition?.variables ?? []}}",
"template_code": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ?? ''}}",
"template_name": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name ?? ''}}",
"fallback_sms": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ?? false}}",
"isSaving": false
}
}
},
{
"handler": "openModal",
"target": "modal_bizppurio_binding"
},
{
"comment": "승인 템플릿 드롭다운 옵션 조회(모달 열 때만 → 전 탭 에러 없음). 자격증명 미설정 시 422 는 fallback 으로 조용히 처리되며, openModal 뒤에 두어 조회 실패가 모달 표시를 막지 않게 한다.",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioApprovedTemplates"
}
}
]
}
}
]
}
]
}
],
"priority": 320
}
@@ -0,0 +1,483 @@
{
"target_layout": "admin_board_settings",
"comment": "게시판 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 게시판 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 게시판 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).",
"data_sources": [
{
"id": "bizppurioBindings",
"comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"fallback": {
"data": {
"bindings": {}
}
}
},
{
"id": "bizppurioApprovedTemplates",
"comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"errorHandling": {
"default": {
"handler": "suppress"
}
},
"fallback": {
"data": {
"templates": [],
"load_failed": true
}
}
}
],
"injections": [
{
"target_id": "board_notif_channel_content",
"position": "prepend_child",
"comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
"components": [
{
"id": "bizppurio_board_status_banner",
"type": "basic",
"name": "Div",
"if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}",
"props": {
"className": "mt-4 mb-3 space-y-2"
},
"children": [
{
"id": "bizppurio_board_banner_not_ready",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}",
"props": {
"className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-exclamation",
"size": "sm",
"className": "text-red-600 dark:text-red-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.not_ready"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
},
"text": "$t:sirsoft-message_bizppurio.banner.setup_action",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins/sirsoft-message_bizppurio/settings"
}
}
]
}
]
},
{
"id": "bizppurio_board_banner_test_mode",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}",
"props": {
"className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-flask",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.test_mode"
}
]
}
]
},
{
"id": "bizppurio_board_alimtalk_guide",
"comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
"type": "basic",
"name": "Div",
"if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}",
"props": {
"className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-blue-800 dark:text-blue-200"
},
"text": "$t:sirsoft-message_bizppurio.binding.list_guide"
}
]
}
]
}
],
"modals": [
{
"id": "modal_bizppurio_binding",
"comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}",
"size": "md",
"closeOnOverlayClick": false
},
"children": [
{
"id": "bizppurio_board_binding_modal_body",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}",
"props": {
"className": "space-y-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.connected_template"
},
{
"type": "composite",
"name": "Select",
"props": {
"value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"className": "w-full text-sm",
"options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.template_code": "{{$event.target.value}}",
"bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
"bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
"props": {
"className": "text-xs text-red-600 dark:text-red-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
},
{
"comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
"disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary text-xs"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
}
]
},
{
"id": "bizppurio_board_binding_fallback_hint",
"comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
},
{
"id": "bizppurio_board_binding_variables",
"type": "basic",
"name": "Div",
"if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}",
"props": {
"className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{_global.bizppurio_binding_modal?.variables ?? []}}",
"item_var": "v"
},
"props": {
"className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
},
"text": "{{'#{' + (v.key ?? '') + '}'}}"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "modal_bizppurio_binding"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.save",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"params": {
"method": "POST",
"body": {
"notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}",
"template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}",
"fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.saved",
"type": "success"
}
},
{
"handler": "closeModal",
"target": "modal_bizppurio_binding"
},
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.save_error",
"type": "error"
}
}
]
}
]
}
}
]
}
]
}
]
}
],
"init_actions": [
{
"comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
}
],
"priority": 320
}
@@ -0,0 +1,483 @@
{
"target_layout": "admin_settings",
"comment": "알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(계획서 §6-2, Phase 6 재설계). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼(channel==='alimtalk'일 때만) ③ [연결] 클릭 시 우리 연결 모달(modals 병합)에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API. 편집 모달(본문 편집)과 완전히 분리되어 헷갈리지 않는다.",
"data_sources": [
{
"id": "bizppurioBindings",
"comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"fallback": {
"data": {
"bindings": {}
}
}
},
{
"id": "bizppurioApprovedTemplates",
"comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용, 토스트 재도입 금지 — 모달 열 때마다 에러 토스트가 뜨던 회귀). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다 — 정상 응답에는 이 필드가 없으므로, 드롭다운이 비었을 때 load_failed 유무로 '불러오지 못함(자격증명 확인)' vs '승인 템플릿 없음'을 분기한다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"errorHandling": {
"default": {
"handler": "suppress"
}
},
"fallback": {
"data": {
"templates": [],
"load_failed": true
}
}
}
],
"injections": [
{
"target_id": "notif_channel_content",
"position": "prepend_child",
"comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
"components": [
{
"id": "bizppurio_status_banner",
"type": "basic",
"name": "Div",
"if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}",
"props": {
"className": "mt-4 mb-3 space-y-2"
},
"children": [
{
"id": "bizppurio_banner_not_ready",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}",
"props": {
"className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-exclamation",
"size": "sm",
"className": "text-red-600 dark:text-red-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.not_ready"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
},
"text": "$t:sirsoft-message_bizppurio.banner.setup_action",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins/sirsoft-message_bizppurio/settings"
}
}
]
}
]
},
{
"id": "bizppurio_banner_test_mode",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}",
"props": {
"className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-flask",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.test_mode"
}
]
}
]
},
{
"id": "bizppurio_alimtalk_guide",
"comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
"type": "basic",
"name": "Div",
"if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}",
"props": {
"className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-blue-800 dark:text-blue-200"
},
"text": "$t:sirsoft-message_bizppurio.binding.list_guide"
}
]
}
]
}
],
"modals": [
{
"id": "modal_bizppurio_binding",
"comment": "알림톡 연결 전용 모달(우리 소유, 신규). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}",
"size": "md",
"closeOnOverlayClick": false
},
"children": [
{
"id": "bizppurio_binding_modal_body",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}",
"props": {
"className": "space-y-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.connected_template"
},
{
"type": "composite",
"name": "Select",
"props": {
"value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"className": "w-full text-sm",
"options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.template_code": "{{$event.target.value}}",
"bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
"bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
"props": {
"className": "text-xs text-red-600 dark:text-red-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
},
{
"comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
"disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary text-xs"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
}
]
},
{
"id": "bizppurio_binding_fallback_hint",
"comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
},
{
"id": "bizppurio_binding_variables",
"type": "basic",
"name": "Div",
"if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}",
"props": {
"className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{_global.bizppurio_binding_modal?.variables ?? []}}",
"item_var": "v"
},
"props": {
"className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
},
"text": "{{'#{' + (v.key ?? '') + '}'}}"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "modal_bizppurio_binding"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.save",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"params": {
"method": "POST",
"body": {
"notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}",
"template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}",
"fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.saved",
"type": "success"
}
},
{
"handler": "closeModal",
"target": "modal_bizppurio_binding"
},
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.save_error",
"type": "error"
}
}
]
}
]
}
}
]
}
]
}
]
}
],
"init_actions": [
{
"comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
}
],
"priority": 320
}
@@ -0,0 +1,483 @@
{
"target_layout": "admin_ecommerce_settings",
"comment": "이커머스 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 이커머스 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 이커머스 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).",
"data_sources": [
{
"id": "bizppurioBindings",
"comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"fallback": {
"data": {
"bindings": {}
}
}
},
{
"id": "bizppurioApprovedTemplates",
"comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.",
"type": "api",
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
"method": "GET",
"auto_fetch": false,
"auth_required": true,
"errorHandling": {
"default": {
"handler": "suppress"
}
},
"fallback": {
"data": {
"templates": [],
"load_failed": true
}
}
}
],
"injections": [
{
"target_id": "ecommerce_notif_channel_content",
"position": "prepend_child",
"comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
"components": [
{
"id": "bizppurio_ecommerce_status_banner",
"type": "basic",
"name": "Div",
"if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}",
"props": {
"className": "mt-4 mb-3 space-y-2"
},
"children": [
{
"id": "bizppurio_ecommerce_banner_not_ready",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}",
"props": {
"className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-exclamation",
"size": "sm",
"className": "text-red-600 dark:text-red-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-red-800 dark:text-red-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.not_ready"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
},
"text": "$t:sirsoft-message_bizppurio.banner.setup_action",
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins/sirsoft-message_bizppurio/settings"
}
}
]
}
]
},
{
"id": "bizppurio_ecommerce_banner_test_mode",
"type": "basic",
"name": "Div",
"if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}",
"props": {
"className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-flask",
"size": "sm",
"className": "text-amber-600 dark:text-amber-400"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-amber-800 dark:text-amber-200"
},
"text": "$t:sirsoft-message_bizppurio.banner.test_mode"
}
]
}
]
},
{
"id": "bizppurio_ecommerce_alimtalk_guide",
"comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
"type": "basic",
"name": "Div",
"if": "{{(_local.activeNotificationChannel ?? query.channel) === 'alimtalk'}}",
"props": {
"className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-sm text-blue-800 dark:text-blue-200"
},
"text": "$t:sirsoft-message_bizppurio.binding.list_guide"
}
]
}
]
}
],
"modals": [
{
"id": "modal_bizppurio_binding",
"comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.",
"type": "composite",
"name": "Modal",
"props": {
"title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}",
"size": "md",
"closeOnOverlayClick": false
},
"children": [
{
"id": "bizppurio_ecommerce_binding_modal_body",
"type": "basic",
"name": "Div",
"blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}",
"props": {
"className": "space-y-4"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "fas fa-circle-info",
"size": "sm",
"className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
}
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-xs text-teal-700 dark:text-teal-300"
},
"text": "$t:sirsoft-message_bizppurio.binding.section_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "text-secondary block text-xs font-medium mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.connected_template"
},
{
"type": "composite",
"name": "Select",
"props": {
"value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"className": "w-full text-sm",
"options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.template_code": "{{$event.target.value}}",
"bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
"bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
"props": {
"className": "text-xs text-red-600 dark:text-red-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
},
{
"comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
"type": "basic",
"name": "P",
"if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
"props": {
"className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex-center gap-2"
},
"children": [
{
"type": "composite",
"name": "Toggle",
"props": {
"checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
"disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
}
}
]
},
{
"type": "basic",
"name": "Span",
"props": {
"className": "text-secondary text-xs"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
}
]
},
{
"id": "bizppurio_ecommerce_binding_fallback_hint",
"comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
},
{
"id": "bizppurio_ecommerce_binding_variables",
"type": "basic",
"name": "Div",
"if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}",
"props": {
"className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
},
"children": [
{
"type": "basic",
"name": "P",
"props": {
"className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
},
"text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-wrap gap-1"
},
"children": [
{
"type": "basic",
"name": "Span",
"iteration": {
"source": "{{_global.bizppurio_binding_modal?.variables ?? []}}",
"item_var": "v"
},
"props": {
"className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
},
"text": "{{'#{' + (v.key ?? '') + '}'}}"
}
]
}
]
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.cancel",
"actions": [
{
"type": "click",
"handler": "closeModal",
"target": "modal_bizppurio_binding"
}
]
},
{
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50",
"disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
},
"text": "$t:common.save",
"actions": [
{
"type": "click",
"handler": "sequence",
"params": {
"actions": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": true
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
"params": {
"method": "POST",
"body": {
"notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}",
"template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
"template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}",
"fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}"
}
},
"onSuccess": [
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.saved",
"type": "success"
}
},
{
"handler": "closeModal",
"target": "modal_bizppurio_binding"
},
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "global",
"bizppurio_binding_modal.isSaving": false
}
},
{
"handler": "toast",
"params": {
"message": "$t:sirsoft-message_bizppurio.binding.save_error",
"type": "error"
}
}
]
}
]
}
}
]
}
]
}
]
}
],
"init_actions": [
{
"comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
"handler": "refetchDataSource",
"params": {
"dataSourceId": "bizppurioBindings"
}
}
],
"priority": 320
}
@@ -0,0 +1,309 @@
/**
* 비즈뿌리오 메시징 플러그인 알림톡 템플릿 조회 탭 구조 검증 (조회 전용)
*
* plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 화면을 검증한다.
* 조회 전용 전환: 등록·수정·삭제·검수·상태변경을 제거하고 목록·상태·내용 조회 + 알림 연결만
* 남겼다. 등록·관리는 비즈뿌리오 콘솔로 위임한다.
* - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(query.tab)
* - 목록 서브뷰(상태필터·검색·상태배지·[내용] 버튼) — [새 템플릿]·폼·상태변경 없음
* - 상세 모달([닫기]만, 관리 액션 없음) / readiness 안내 / 준비 안내 / i18n 정합
*
* 독립 페이지·메뉴 없음: 설정 페이지 탭으로만 진입.
*/
import { describe, it, expect } from 'vitest';
import layout from '../../../layouts/admin/plugin_settings.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, collectI18nKeys, type AnyNode } from './helpers';
const root = layout as unknown as AnyNode;
describe('alimtalk templates — 탭 네비게이션', () => {
it('탭 네비게이션에 환경설정·알림톡 템플릿 탭 버튼이 있다', () => {
const nav = findById(root, 'settings_tabs');
expect(nav).toBeTruthy();
expect(findById(nav, 'tab_connection')).toBeTruthy();
expect(findById(nav, 'tab_templates')).toBeTruthy();
});
it('알림톡 탭 버튼이 navigate replace 로 query.tab=templates 를 갱신하고 목록을 refetch 한다', () => {
const raw = JSON.stringify(findById(root, 'tab_templates'));
// 탭 전환은 navigate replace(+mergeQuery)로 if 재평가 → 화면 전환 (replaceUrl 은 if 미재평가라 부적합)
expect(raw).toContain('"handler":"navigate"');
expect(raw).toContain('"replace":true');
expect(raw).toContain('"tab":"templates"');
expect(raw).toContain('alimtalk_templates');
});
it('탭 패널이 query.tab 으로 배타 전환된다 (새로고침에도 유지)', () => {
const connection = findById(root, 'connection_tab_panel');
const templates = findById(root, 'templates_tab_panel');
expect((connection as { if?: string }).if).toContain("query.tab ?? 'connection') === 'connection'");
expect((templates as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
});
it('init_actions 가 templates 탭 진입 시 목록을 자동 로드한다 (새로고침 복원)', () => {
const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? [];
const refetch = inits.find((a) => a.handler === 'refetchDataSource');
expect(refetch).toBeTruthy();
expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
});
});
describe('alimtalk templates — 데이터소스', () => {
it('템플릿 목록 데이터소스가 admin API 를 조회한다 (조회 전용 — 카테고리 데이터소스 없음)', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const list = sources.find((s) => s.id === 'alimtalk_templates');
const cats = sources.find((s) => s.id === 'alimtalk_categories');
expect(list?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates');
expect(list?.auto_fetch).toBe(false);
// 카테고리 데이터소스는 등록 폼 전용이라 제거됨
expect(cats).toBeUndefined();
});
});
describe('alimtalk templates — 조회 전용 (폼·관리 제거)', () => {
it('폼 서브뷰(templates_form_view)가 제거되었다', () => {
expect(findById(root, 'templates_form_view')).toBeNull();
});
it('상태변경 확인/실행 모달(alimtalk_template_action_modal)이 제거되었다', () => {
const modals = (root as { modals?: AnyNode[] }).modals ?? [];
expect(modals.find((m) => m.id === 'alimtalk_template_action_modal')).toBeUndefined();
});
it('폼 전환·관리 상태(templateView/templateForm/pendingAction/available_actions)가 레이아웃에 없다', () => {
const raw = JSON.stringify(root);
expect(raw).not.toContain('templateView');
expect(raw).not.toContain('templateForm');
expect(raw).not.toContain('pendingAction');
expect(raw).not.toContain('available_actions');
// 이미지 업로드 핸들러도 제거
expect(raw).not.toContain('uploadTemplateImage');
});
});
describe('alimtalk templates — 목록 서브뷰 (조회 전용)', () => {
it('상태 필터 Select 와 검색 입력이 있다', () => {
const toolbar = findById(root, 'templates_toolbar');
const raw = JSON.stringify(toolbar);
expect(raw).toContain('templateStatus');
expect(raw).toContain('templateKeyword');
});
it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다 (캐시 초기화는 환경설정 탭으로 이관)', () => {
const raw = JSON.stringify(findById(root, 'templates_toolbar'));
expect(raw).toContain('templates.list.refresh');
// 캐시 초기화·캐시 시간은 환경설정 탭으로 옮겼으므로 템플릿 툴바에는 없다.
expect(raw).not.toContain('cache/clear');
// 등록 진입(폼 전환) 없음
expect(raw).not.toContain('templates.list.new');
expect(raw).not.toContain('"form"');
});
it('빈 목록에서도 헤더 있는 표(카드)를 항상 표시하고 데이터 유무로 Tbody 를 분기한다', () => {
const card = findById(root, 'templates_table_card');
expect(card).toBeTruthy();
const raw = JSON.stringify(card);
// 표 헤더는 항상 존재
expect(raw).toContain('columns.name');
expect(raw).toContain('columns.status');
// 데이터 유무 분기 (빈 상태 안내 + 목록 iteration)
expect(raw).toContain('.length > 0');
expect(raw).toContain('.length === 0');
expect(raw).toContain('templates.list.empty');
});
it('표가 templates 목록을 iteration 으로 렌더한다', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
expect(raw).toContain('alimtalk_templates?.data?.templates');
expect(raw).toContain('"item_var":"tpl"');
expect(raw).toContain('tpl.templateName');
expect(raw).toContain('status_badge');
});
it('관리 컬럼이 [내용] 버튼 하나로 상세 조회 후 상세 모달을 연다 (상태별 액션 없음)', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
// 상세 조회 → 상세 모달
expect(raw).toContain('templates.actions.detail');
expect(raw).toContain('"handler":"apiCall"');
expect(raw).toContain('alimtalk_template_detail_modal');
// 관리 액션 메뉴(ActionMenu)·switch 분기 없음
expect(raw).not.toContain('ActionMenu');
expect(raw).not.toContain('"handler":"switch"');
expect(raw).not.toContain("id:'edit'");
expect(raw).not.toContain("id:'delete'");
});
it('목록에 번호·등록요청일·처리일 컬럼이 있다', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
expect(raw).toContain('columns.no');
expect(raw).toContain('columns.requested_at');
expect(raw).toContain('columns.processed_at');
// 순번은 pagination 기준 계산 + index_var
expect(raw).toContain('"index_var":"tplIndex"');
expect(raw).toContain('pagination?.current_page');
// 날짜는 kapi 원본 필드
expect(raw).toContain('tpl.createdAt');
expect(raw).toContain('tpl.modifiedAt');
});
it('상태 배지가 RDY 일 때만 세부(사용전)를 덧붙인다', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
expect(raw).toContain("tpl.service_status === 'RDY'");
expect(raw).toContain('status_sub.rdy');
});
it('페이지네이션이 page 상태를 갱신하고 목록을 refetch 한다', () => {
const listView = JSON.stringify(findById(root, 'templates_list_view'));
expect(listView).toContain('"name":"Pagination"');
expect(listView).toContain('onPageChange');
expect(listView).toContain('templatePage');
expect(listView).toContain('pagination?.total_page');
});
});
describe('alimtalk templates — 콘솔 안내 (조회 전용)', () => {
it('목록 상단 안내가 배지 의미(제목+배지명+설명) + 콘솔 위임 안내를 포함한다', () => {
const raw = JSON.stringify(findById(root, 'templates_list_notice'));
// 배지 의미 — 제목 + 배지명/설명 분리(의미색 라벨)
expect(raw).toContain('status_guide.title');
expect(raw).toContain('status_guide.sendable_label');
expect(raw).toContain('status_guide.inspecting_label');
expect(raw).toContain('status_guide.pending_label');
// 배지명은 의미색(초록/amber/빨강) solid 로 표의 배지와 매칭
expect(raw).toContain('text-green-700');
expect(raw).toContain('text-amber-700');
expect(raw).toContain('text-red-700');
// 콘솔 위임 안내 + 콘솔 링크
expect(raw).toContain('list_notice.console_desc');
expect(raw).toContain('list_notice.console_link');
expect(raw).toContain('bizppurio.com');
});
it('환경설정 탭에 사용 전 준비 안내(카카오·SMS 통합 1박스)가 있고 info_panel 은 제거되었다', () => {
const notice = findById(root, 'preparation_notice');
expect(notice).toBeTruthy();
const raw = JSON.stringify(notice);
// 통합 박스: 문자(발신번호) + 카카오(채널·템플릿·API키 3단계) + 콘솔 링크 (박스 제목 없이 채널 그룹만)
expect(raw).toContain('preparation.sms_label');
expect(raw).toContain('preparation.sms_sender');
expect(raw).toContain('preparation.kakao_label');
expect(raw).toContain('preparation.kakao_channel');
expect(raw).toContain('preparation.kakao_template');
expect(raw).toContain('preparation.kakao_apikey');
expect(raw).toContain('preparation.console_link');
// 제목·본문 sm 로 상향(가독성)
expect(raw).toContain('text-sm');
// 중복이던 연동정보 안내(info_panel)는 제거(운영전환 경고·리포트 안내는 폼/리포트 섹션이 담당)
expect(findById(root, 'info_panel')).toBeNull();
});
});
describe('alimtalk templates — 상세 모달 & readiness (조회 전용)', () => {
it('상세 모달이 정의되어 있다', () => {
const modals = (root as { modals?: AnyNode[] }).modals ?? [];
const detail = modals.find((m) => m.id === 'alimtalk_template_detail_modal');
expect(detail).toBeTruthy();
});
it('상세 모달이 상태 배지·내용을 표시하고 [닫기]만 노출한다 (관리 액션 없음)', () => {
const modals = (root as { modals?: AnyNode[] }).modals ?? [];
const detail = JSON.stringify(modals.find((m) => m.id === 'alimtalk_template_detail_modal'));
// 상태 배지 + 내용(카테고리·유형·버튼) 표시
expect(detail).toContain('status_badge');
expect(detail).toContain('templates.detail.category');
expect(detail).toContain('templates.detail.buttons');
expect(detail).toContain('columns.requested_at');
// 닫기만 — 관리 액션(available_actions)·상태변경 모달 연결 없음
expect(detail).toContain('templates.detail.close');
expect(detail).not.toContain('available_actions');
expect(detail).not.toContain('alimtalk_template_action_modal');
});
it('readiness 안내가 미준비(ready=false) 시 조건부로 표시되고 항목별 상태를 노출한다', () => {
const readiness = findById(root, 'templates_readiness');
// 최상위 노출 조건은 ready 플래그 기반
const cond = (readiness as { if?: string }).if ?? '';
expect(cond).toContain('templates_readiness?.data');
expect(cond).toContain('ready');
// 자식 노드에서 항목별 미설정(api_key_set/sender_key_set) 조건부 표시
const raw = JSON.stringify(readiness);
expect(raw).toContain('api_key_set');
expect(raw).toContain('sender_key_set');
});
});
describe('alimtalk templates — 회귀', () => {
it('컴포넌트 최상위 actions 는 모두 이벤트 type 을 가진다 (charAt 회귀 방지)', () => {
// 회귀: 컴포넌트의 actions 배열 최상위 항목에 type(click 등) 이 없으면 엔진이
// getReactEventName 에서 eventType.charAt(0) 을 호출하다 "Cannot read properties of
// undefined (reading 'charAt')" 로 그 컴포넌트 렌더가 통째로 실패한다(PO 브라우저 검수로 발견).
const bad: string[] = [];
const walk = (node: AnyNode, path: string): void => {
if (Array.isArray(node)) {
node.forEach((n, i) => walk(n as AnyNode, `${path}[${i}]`));
return;
}
if (node && typeof node === 'object') {
const obj = node as Record<string, unknown>;
// 컴포넌트의 actions 만 검사 (sequence/switch 내부 하위 actions 는 handler 를 가짐 → 제외)
if (Array.isArray(obj.actions) && obj.handler === undefined) {
(obj.actions as Array<Record<string, unknown>>).forEach((a, i) => {
if (a && typeof a === 'object' && a.type === undefined) {
bad.push(`${path}.actions[${i}] handler=${String(a.handler)}`);
}
});
}
for (const k of Object.keys(obj)) walk(obj[k] as AnyNode, `${path}.${k}`);
}
};
walk(root, 'root');
expect(bad, `type 누락 최상위 액션:\n${bad.join('\n')}`).toEqual([]);
});
});
describe('alimtalk templates — i18n 정합', () => {
it('레이아웃 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => {
const keys = collectI18nKeys(layout);
const resolve = (dict: Record<string, unknown>, path: string): unknown =>
path.split('.').reduce<unknown>((acc, seg) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[seg];
}
return undefined;
}, dict);
for (const raw of keys) {
const path = raw.replace('$t:sirsoft-message_bizppurio.', '');
expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy();
expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy();
}
});
});
describe('alimtalk templates — 환경설정 탭: 발송 내용 캐시 (독립 카드)', () => {
it('발송 내용 캐시가 독립 카드로 있고 캐시 시간(분) 입력을 담는다 (form 저장 대상)', () => {
const card = findById(root, 'cache_section');
expect(card).toBeTruthy();
const raw = JSON.stringify(card);
// 카드 제목 + 분 단위 숫자 입력 + form 바인딩(name)
expect(raw).toContain('settings.cache.section_title');
expect(raw).toContain('template_cache_minutes');
expect(raw).toContain('"number"');
expect(raw).toContain('settings.fields.template_cache_minutes.label');
expect(raw).toContain('settings.fields.template_cache_minutes.hint');
});
it('같은 카드에 캐시 초기화 버튼 + 안내문이 함께 있다 (한 묶음)', () => {
const raw = JSON.stringify(findById(root, 'cache_section'));
// 즉시 캐시 비우기 — API 호출 + 성공/실패 토스트 + 상시 안내문
expect(raw).toContain('alimtalk-templates/cache/clear');
expect(raw).toContain('apiCall');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_hint');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_success');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_failed');
});
});
@@ -0,0 +1,120 @@
/**
* 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼
*
* JSON 트리에서 ID/이름 검색 및 핸들러·i18n 키 추출을 위한 유틸리티.
*/
export type AnyNode = Record<string, unknown> & {
id?: string;
name?: string;
type?: string;
children?: AnyNode[];
slots?: Record<string, AnyNode[]>;
modals?: Record<string, AnyNode> | AnyNode[];
actions?: AnyNode[];
iteration?: { source?: string; item_var?: string; index_var?: string };
if?: string;
props?: Record<string, unknown>;
text?: string;
};
/**
* 트리 노드에서 특정 id를 재귀 탐색 (children/slots/modals)
*/
export function findById(node: AnyNode | undefined | null, id: string): AnyNode | null {
if (!node) return null;
if (node.id === id) return node;
if (Array.isArray(node.children)) {
for (const child of node.children) {
const found = findById(child, id);
if (found) return found;
}
}
if (node.slots && typeof node.slots === 'object') {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findById(child, id);
if (found) return found;
}
}
}
}
if (node.modals) {
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
for (const m of modalEntries) {
const found = findById(m as AnyNode, id);
if (found) return found;
}
}
return null;
}
/**
* 트리 노드에서 특정 name 컴포넌트를 모두 수집 (children/slots/modals)
*/
export function findAllByName(node: AnyNode | undefined | null, name: string): AnyNode[] {
const results: AnyNode[] = [];
if (!node) return results;
if (node.name === name) results.push(node);
if (Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findAllByName(child, name));
}
}
if (node.slots && typeof node.slots === 'object') {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...findAllByName(child, name));
}
}
}
}
if (node.modals) {
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
for (const m of modalEntries) {
results.push(...findAllByName(m as AnyNode, name));
}
}
return results;
}
/**
* 특정 name 컴포넌트 중 props.name 속성이 주어진 값인 첫 노드를 찾음 (폼 입력 필드 탐색용)
*/
export function findInputByName(node: AnyNode | undefined | null, inputName: string): AnyNode | null {
for (const candidate of [...findAllByName(node, 'Input'), ...findAllByName(node, 'Select')]) {
if ((candidate.props as { name?: string } | undefined)?.name === inputName) {
return candidate;
}
}
return null;
}
/**
* JSON 문자열에서 사용된 핸들러 이름 모두 수집
*/
export function collectHandlers(json: unknown): string[] {
const text = JSON.stringify(json);
const matches = text.match(/"handler":\s*"([^"]+)"/g) ?? [];
const names = matches
.map((m) => m.match(/"handler":\s*"([^"]+)"/)?.[1] ?? '')
.filter(Boolean);
return Array.from(new Set(names));
}
/**
* JSON 문자열에서 사용된 i18n 키($t:sirsoft-message_bizppurio.*) 모두 수집
*/
export function collectI18nKeys(json: unknown): string[] {
const text = JSON.stringify(json);
const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
return Array.from(new Set(matches));
}
@@ -0,0 +1,339 @@
// e2e:allow 검수 라벨 표시(is_test_mode)는 Chrome MCP로 실제 회원가입→SMS 발송→화면 렌더까지
// 실측 검증 완료(2026-07-24). 정식 E2E는 비즈뿌리오 자격증명·채널 활성화·webhook 등 발송 인프라
// 의존이 커서 이번 변경 범위를 벗어나며, 별도 계획(plan-e2e-tests)에서 다룰 예정.
/**
* 코어 알림 발송 이력 결과 컬럼 주입 렌더 테스트 (A-2)
*
* notification_log_result.json overlay 는 코어 "알림 발송 이력" 화면(admin_notification_log_list)의
* DataGrid columns 에 결과 컬럼 1개를 _append 로 얹는다. 코어 화면·코어 앱·코어 테이블 무수정 —
* 현재 페이지의 코어 로그 id 배열로 결과(dispatchResults)를 배치 조회해 row.id 로 매칭한다.
*
* 이 테스트는 overlay 에서 결과 컬럼 정의를 그대로 추출해 실제 렌더한다(구조 검증이 아니라 렌더).
* row 컨텍스트는 iteration 으로 재현하고, dispatchResults 는 mockApi 로 채운다. 각 케이스에서
* 올바른 배지 텍스트(상태 `사유 (코드)`·잔액부족·대체발송)가 DOM 에 뜨는지, 매칭 안 되는 행은
* 빈 셀(-)인지 확인한다.
*
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
createLayoutTest,
createMockComponentRegistryWithBasics,
screen,
type MockComponentRegistry,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
import overlay from '../../../extensions/notification_log_result.json';
/** overlay 에서 결과 컬럼(field: bizppurio_result)의 cellChildren 을 추출한다. */
function getResultCellChildren(): any[] {
const inj = (overlay as any).injections.find(
(i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props',
);
const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result');
if (!column) throw new Error('결과 컬럼(bizppurio_result)을 찾지 못함');
return column.cellChildren;
}
/** overlay 에서 행 토글(expandChildren) 에 append 된 결과 블록을 추출한다. */
function getResultExpandChildren(): any[] {
const inj = (overlay as any).injections.find(
(i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props',
);
return inj.props.expandChildren._append;
}
/** 결과 컬럼 cellChildren 을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */
function buildProbe() {
return {
version: '1.0.0',
layout_name: 'test/a2-dispatch-result',
data_sources: [
{ id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true },
{ id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true },
],
components: [
{
type: 'basic',
name: 'Div',
iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' },
props: { 'data-testid': 'result-cell' },
children: getResultCellChildren(),
},
],
};
}
/** 행 토글(expandChildren) 결과 블록을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */
function buildExpandProbe() {
return {
version: '1.0.0',
layout_name: 'test/a2-dispatch-result-expand',
data_sources: [
{ id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true },
{ id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true },
],
components: [
{
type: 'basic',
name: 'Div',
iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' },
props: { 'data-testid': 'result-expand' },
children: getResultExpandChildren(),
},
],
};
}
let registry: MockComponentRegistry;
beforeEach(() => {
registry = createMockComponentRegistryWithBasics();
});
afterEach(() => {
vi.clearAllMocks();
});
/** 로그 행 목록. 셀 탭 가드가 row.channel 을 보므로 채널을 함께 준다(기본 sms). */
const logs = (ids: number[], channel: string = 'sms') => ({ data: { data: ids.map((id) => ({ id, channel })) } });
describe('A-2 결과 컬럼 주입 — overlay 구조', () => {
it('코어 알림 발송 이력 datagrid 에 결과 컬럼을 _append 로 얹는다', () => {
expect((overlay as any).target_layout).toBe('admin_notification_log_list');
const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
expect(inj.position).toBe('inject_props');
expect(inj.props.columns._append).toBeTruthy();
});
it('결과는 파라미터 없이 GET 으로 최근 결과 맵을 받는다(타이밍 무관, kginicis 선례)', () => {
// 다른 data_source(notificationLogs)를 params 로 참조하면, params 가 notificationLogs 응답
// 도착 전에 평가돼 빈 배열이 전송되는 타이밍 결함이 있다(브라우저 실측: 결과 컬럼 전부 빈 셀).
// 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭한다(로드 순서 무관).
const ds = (overlay as any).data_sources.find((d: any) => d.id === 'dispatchResults');
expect(ds.method).toBe('GET');
expect(ds.endpoint).toContain('/dispatch-results/recent');
expect(ds.params).toBeUndefined();
expect(ds.if).toBeUndefined();
});
it('결과 컬럼 셀은 비즈뿌리오 발송 행(row.channel)에서만 표시한다(메일·사이트내알림 숨김)', () => {
const cell = getResultCellChildren();
const guard = cell[0];
// 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없다(코어 DataGrid renderCellChildren 계약).
// 따라서 탭(query.channel)이 아니라 row.channel 로 비즈뿌리오 발송 행을 판별한다.
expect(guard.if).toContain("['sms','lms','alimtalk'].includes(row.channel)");
});
it('컬럼(헤더 포함)은 메일·사이트내알림 탭에서 hidden 으로 숨긴다', () => {
// 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로 query 접근 가능(셀과 다름).
// 메일·사이트내알림(mail/database) 탭이면 hidden=true → 헤더까지 숨긴다.
const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result');
expect(column.hidden).toContain("['mail','database'].includes(query.channel");
});
it('행 토글(expandChildren)에도 결과 블록을 append 한다(비즈뿌리오 발송 행만)', () => {
const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
const appended = inj.props.expandChildren._append;
expect(appended).toBeTruthy();
const block = appended[0];
// 결과가 매칭된 행일 때만 토글에 노출.
expect(block.if).toContain('dispatchResults?.data?.results');
expect(JSON.stringify(block)).toContain('dispatch_result.detail_title');
});
it('배지는 다크모드에서 solid 색을 쓴다(색-불투명도 희석 금지)', () => {
const raw = JSON.stringify(overlay);
// /40 등 색-불투명도는 작은 배지에서 다크 배경과 섞여 묽어지므로 solid(dark:bg-*-700 등) 사용.
expect(raw).toContain('dark:bg-green-700');
expect(raw).toContain('dark:bg-red-700');
expect(raw).not.toContain('dark:bg-green-900/40');
});
});
describe('A-2 결과 컬럼 주입 — 렌더', () => {
it('성공 결과는 사유(코드) 라벨을 렌더한다', async () => {
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([1]) });
utils.mockApi('dispatchResults', {
response: { data: { results: { 1: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } },
});
await utils.render();
expect(screen.getByText('성공 (4100)')).toBeInTheDocument();
utils.cleanup();
});
it('잔액부족 실패는 사유(코드) 라벨과 잔액부족 배지를 렌더한다', async () => {
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([2]) });
utils.mockApi('dispatchResults', {
response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '지갑 잔액 부족 (7436)', is_low_balance: true, fallback_status: null } } } },
});
await utils.render();
// result_label 은 data 값(그대로 렌더). 잔액부족 배지는 is_low_balance=true 조건부 렌더 —
// $t: 라벨은 이 렌더 환경에서 원문 키로 남으므로 그 키 존재로 배지 렌더를 확인한다.
expect(screen.getByText('지갑 잔액 부족 (7436)')).toBeInTheDocument();
expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).toBeInTheDocument();
utils.cleanup();
});
it('is_low_balance=false 이면 잔액부족 배지를 렌더하지 않는다', async () => {
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([2]) });
utils.mockApi('dispatchResults', {
response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '음영 지역 (4400)', is_low_balance: false, fallback_status: null } } } },
});
await utils.render();
expect(screen.getByText('음영 지역 (4400)')).toBeInTheDocument();
expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).not.toBeInTheDocument();
utils.cleanup();
});
it('대체발송 결과가 있으면 대체발송 배지를 렌더한다', async () => {
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([3]) });
utils.mockApi('dispatchResults', {
response: { data: { results: { 3: { status: 'success', status_label: '성공', result_label: '성공 (7000)', is_low_balance: false, fallback_status: '성공' } } } },
});
await utils.render();
// 대체발송 배지는 fallback_status 존재 시 조건부 렌더 ($t: 라벨은 원문 키로 남음).
expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.fallback')).toBeInTheDocument();
utils.cleanup();
});
it('비즈뿌리오 발송(sms) 행이지만 결과 미매칭이면 빈 셀(-)을 렌더한다', async () => {
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([9], 'sms') });
utils.mockApi('dispatchResults', { response: { data: { results: {} } } });
await utils.render();
expect(screen.getByText('-')).toBeInTheDocument();
utils.cleanup();
});
it('메일 채널 행은 셀 자체를 비운다(빈 셀 - 도 표시 안 함)', async () => {
// row.channel 이 mail 이면 셀 최상위 가드가 false → 셀 내용(빈 셀 - 포함) 전체 미렌더.
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([10], 'mail') });
utils.mockApi('dispatchResults', { response: { data: { results: {} } } });
await utils.render();
expect(screen.queryByText('-')).not.toBeInTheDocument();
utils.cleanup();
});
it('검수 모드 발송 건은 상태 라벨(발송중) 대신 검수 라벨을 렌더한다', async () => {
// is_test_mode=true 이면 status='sent'(발송중)이어도 검수 라벨로 대체 표시한다(PO 확정 —
// "발송중" 문구 자체가 검수 모드에서는 오해 소지라 배지 병기가 아니라 라벨 교체).
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([4]) });
utils.mockApi('dispatchResults', {
response: { data: { results: { 4: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: true } } } },
});
await utils.render();
// $t('key') 는 이 렌더 환경에서 번역 실패 시 원문 키를 그대로 반환한다($t: 와 동일 폴백).
expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).toBeInTheDocument();
expect(screen.queryByText('발송중')).not.toBeInTheDocument();
utils.cleanup();
});
it('운영 모드 발송 건은 검수 라벨 없이 기존 상태 라벨을 그대로 렌더한다', async () => {
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([5]) });
utils.mockApi('dispatchResults', {
response: { data: { results: { 5: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: false } } } },
});
await utils.render();
expect(screen.getByText('발송중')).toBeInTheDocument();
expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument();
utils.cleanup();
});
it('is_test_mode 필드가 없는 과거 이력은 검수 라벨 없이 기존 라벨을 렌더한다', async () => {
// 컬럼 신설 이전 이력(is_test_mode 미포함)도 undefined === true 가 false 이므로 안전하게 기존 라벨.
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([6]) });
utils.mockApi('dispatchResults', {
response: { data: { results: { 6: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } },
});
await utils.render();
expect(screen.getByText('성공 (4100)')).toBeInTheDocument();
expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument();
utils.cleanup();
});
});
describe('A-2 행 토글 — 알림톡 실제 발송 내용', () => {
it('알림톡 채널 + 실제 발송 내용이 있으면 코어 "본문"과 별도로 렌더한다', async () => {
const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([7], 'alimtalk') });
utils.mockApi('dispatchResults', {
response: {
data: {
results: {
7: {
status: 'success',
status_label: '성공',
result_label: '성공 (7000)',
is_low_balance: false,
fallback_status: null,
channel: 'alimtalk',
content: '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.',
},
},
},
},
});
await utils.render();
expect(screen.getByText(/회원가입을 환영합니다/)).toBeInTheDocument();
utils.cleanup();
});
it('sms 채널은 실제 발송 내용 값이 있어도 렌더하지 않는다(코어 본문과 동일하므로 중복 표시 불필요)', async () => {
const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([8], 'sms') });
utils.mockApi('dispatchResults', {
response: {
data: {
results: {
8: {
status: 'success',
status_label: '성공',
result_label: '성공 (4100)',
is_low_balance: false,
fallback_status: null,
channel: 'sms',
content: '문자 본문',
},
},
},
},
});
await utils.render();
expect(screen.queryByText('문자 본문')).not.toBeInTheDocument();
utils.cleanup();
});
it('알림톡 채널이지만 실제 발송 내용이 없으면(과거 이력 등) 렌더하지 않는다', async () => {
const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('notificationLogs', { response: logs([9], 'alimtalk') });
utils.mockApi('dispatchResults', {
response: {
data: {
results: {
9: {
status: 'success',
status_label: '성공',
result_label: '성공 (7000)',
is_low_balance: false,
fallback_status: null,
channel: 'alimtalk',
content: null,
},
},
},
},
});
await utils.render();
expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.sent_content_label')).not.toBeInTheDocument();
utils.cleanup();
});
});
@@ -0,0 +1,238 @@
// e2e:allow 검수 모드 배너(readiness 무관 노출)·배너 간격은 Chrome MCP로 알림톡 탭 실제 화면
// 확인·수정까지 완료(2026-07-24). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸.
/**
* 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2)
*
* 두 확장 파일로 분리 구현:
* - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 연결 모달(modals)
* + data_sources. target_layout=admin_settings.
* - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯
* (notification_definition_row_footer)에 연결 상태 줄 + [연결/변경] 버튼.
*
* 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). 연결은 편집 모달과 분리된 우리 전용
* 모달에서 하며, 변경 즉시가 아니라 [저장] 버튼으로 명확히 저장한다. 카카오 API 422 는
* errorHandling.suppress 로 조용히 처리(안내는 배너·문구가 담당).
*
* 검증: 파일 분리(overlay vs extension_point), 배너/안내/모달 구조, 행 슬롯 UI, 저장 배선,
* 무오염(코어 저장 body 미개입), i18n 정합.
*/
import { describe, it, expect } from 'vitest';
import overlay from '../../../extensions/notification_tab_core.json';
import footer from '../../../extensions/notification_row_footer.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, type AnyNode } from './helpers';
const overlayRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
const bannerRoot = {
children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []),
} as AnyNode;
const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode;
/** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */
const collectPluginKeys = (json: unknown): string[] => {
const text = JSON.stringify(json);
const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? [];
return Array.from(new Set([
...prefixed.map((m) => m.replace('$t:', '')),
...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
]));
};
describe('binding UI — 파일 분리(Overlay vs ExtensionPoint)', () => {
it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => {
expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings');
expect((overlay as Record<string, unknown>).extension_point).toBeUndefined();
});
it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => {
expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer');
expect((footer as Record<string, unknown>).target_layout).toBeUndefined();
});
it('overlay 는 연결 맵·승인 템플릿 데이터소스를 등록한다', () => {
const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
expect(ids).toContain('bizppurioBindings');
expect(ids).toContain('bizppurioApprovedTemplates');
});
it('승인 템플릿 데이터소스는 auto_fetch:false 이고 422 를 suppress 한다(전 탭 에러 방지)', () => {
const ds = ((overlay as { data_sources?: Array<Record<string, unknown>> }).data_sources ?? [])
.find((d) => d.id === 'bizppurioApprovedTemplates');
expect(ds?.auto_fetch).toBe(false);
expect(JSON.stringify(ds?.errorHandling)).toContain('suppress');
});
});
describe('binding UI — 상태 배너 + 안내 박스', () => {
it('배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
const banner = findById(bannerRoot, 'bizppurio_status_banner');
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).toContain("'sms'");
expect(cond).toContain("'alimtalk'");
expect(cond).toContain('readiness?.ready === false');
expect(cond).toContain('is_test_mode === true');
});
it('빨강(설정 미완료)·노랑(검수 모드) 배너가 동시에 뜰 때 간격이 있다(회귀: 두 배너가 붙어 보이던 문제)', () => {
const banner = findById(bannerRoot, 'bizppurio_status_banner');
const className = (banner as { props?: { className?: string } }).props?.className ?? '';
expect(className).toMatch(/space-y-\d/);
});
it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready'));
expect(raw).toContain('banner.not_ready');
expect(raw).toContain('banner.setup_action');
expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings');
});
it('검수 모드 배너는 readiness 충족 여부와 무관하게 is_test_mode 만으로 노출된다', () => {
// readiness 실패(예: 알림톡 API 키 미설정) + 검수 모드가 동시에 참인 상황에서도
// 검수 안내가 가려지면 안 된다(회귀: 과거 readiness?.ready !== false 조건이 배너를 숨겼음).
const banner = findById(bannerRoot, 'bizppurio_banner_test_mode');
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).not.toContain('readiness');
expect(cond).toContain('is_test_mode === true');
});
it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => {
const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide');
expect(guide).toBeTruthy();
expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
expect(JSON.stringify(guide)).toContain('binding.list_guide');
});
});
describe('binding UI — 행 연결(extension_point)', () => {
it('행 연결 UI 는 channel === alimtalk 일 때만 노출된다', () => {
const row = findById(footerRoot, 'bizppurio_row_binding');
expect(row).toBeTruthy();
expect((row as { if?: string }).if).toContain("extensionPointProps.activeChannel === 'alimtalk'");
});
it('연결 상태를 bizppurioBindings 에서 def.type 으로 읽어 표시한다(연결됨/미연결)', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
expect(raw).toContain('bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]');
expect(raw).toContain('binding.unbound');
expect(raw).toContain('binding.btn_connect');
expect(raw).toContain('binding.btn_change');
});
it('[연결] 클릭 시 모달 상태를 seed 하고 우리 연결 모달을 연다', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
expect(raw).toContain('bizppurio_binding_modal');
expect(raw).toContain('"openModal"');
expect(raw).toContain('modal_bizppurio_binding');
});
it('연결된 카카오 템플릿이 소실(is_unavailable)이면 빨간 경고 배지를 표시한다(결함 2)', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
// 연결됨(template_code 있음) + is_unavailable === true 일 때만 경고
expect(raw).toContain('is_unavailable === true');
expect(raw).toContain('binding.unavailable');
// 소실 경고는 red 배지로 표시(연결됨 초록과 구분)
expect(raw).toContain('bg-red-100');
});
it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => {
const row = findById(footerRoot, 'bizppurio_row_binding');
const raw = JSON.stringify(row);
const openIdx = raw.indexOf('"openModal"');
const refetchIdx = raw.indexOf('bizppurioApprovedTemplates');
expect(openIdx).toBeGreaterThan(-1);
expect(refetchIdx).toBeGreaterThan(-1);
expect(openIdx).toBeLessThan(refetchIdx);
});
});
describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 분리)', () => {
const modal = findById(overlayRoot, 'modal_bizppurio_binding');
it('연결 전용 모달이 modals 로 등록된다', () => {
expect(modal).toBeTruthy();
expect((modal as { name?: string }).name).toBe('Modal');
});
it('안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 토글 + 변수 안내를 담는다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('binding.section_hint');
expect(raw).toContain('binding.connected_template');
expect(raw).toContain('binding.fallback_sms');
expect(raw).toContain('binding.variables_hint');
});
it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이다', () => {
const raw = JSON.stringify(findById(overlayRoot, 'bizppurio_binding_modal_body'));
expect(raw).toContain('"disabled"');
expect(raw).toContain("=== ''");
});
it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
const raw = JSON.stringify(modal);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('binding.saved');
expect(raw).toContain('binding.save_error');
expect(raw).toContain('"closeModal"');
expect(raw).toContain('bizppurioBindings');
});
});
describe('binding UI — 드롭다운 조회 실패 vs 0건 구분(결함 3)', () => {
const modal = findById(overlayRoot, 'modal_bizppurio_binding');
it('승인 템플릿 데이터소스 fallback 에 load_failed:true 마커가 있다', () => {
const ds = ((overlay as { data_sources?: Array<Record<string, unknown>> }).data_sources ?? [])
.find((d) => d.id === 'bizppurioApprovedTemplates');
const fallback = (ds?.fallback as { data?: Record<string, unknown> })?.data ?? {};
// 조회 실패 시 이 마커가 상태에 실려 '0건'과 구분된다. 정상 응답에는 이 필드가 없다.
expect(fallback.load_failed).toBe(true);
expect(Array.isArray(fallback.templates)).toBe(true);
expect((fallback.templates as unknown[]).length).toBe(0);
});
it('드롭다운이 비었을 때 조회 실패(load_failed)면 설정 확인 문구를 노출한다', () => {
const raw = JSON.stringify(modal);
// 조회 실패 분기: length===0 && load_failed===true → templates_load_failed
expect(raw).toContain('binding.templates_load_failed');
expect(raw).toContain('load_failed === true');
});
it('드롭다운이 비었을 때 조회 정상(0건)이면 승인 템플릿 없음 문구를 노출한다', () => {
const raw = JSON.stringify(modal);
// 0건 분기: length===0 && !load_failed → no_approved_templates
expect(raw).toContain('binding.no_approved_templates');
expect(raw).toContain('!(bizppurioApprovedTemplates?.data?.load_failed)');
});
it('두 문구는 상호배타 조건이라 동시에 뜨지 않는다(조회실패=빨강 / 0건=amber)', () => {
const raw = JSON.stringify(modal);
// 조회 실패 문구는 red, 0건 문구는 amber 로 시각 구분
expect(raw).toContain('text-red-600');
expect(raw).toContain('text-amber-600');
});
});
describe('binding UI — 코어 무오염 + i18n', () => {
it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
const all = JSON.stringify(overlay) + JSON.stringify(footer);
expect(all).not.toContain('/api/admin/notification-templates/');
expect(all).not.toContain('notification_template_form_modal');
});
it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)];
expect(keys.length).toBeGreaterThan(0);
const resolve = (root: unknown, path: string): unknown =>
path.split('.').slice(1).reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], root);
for (const key of Array.from(new Set(keys))) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
}
});
});
@@ -0,0 +1,167 @@
// e2e:allow 게시판·이커머스 배너/버튼/모달 노출은 Chrome MCP 로 실브라우저 확인·수정까지 완료
// (2026-07-28). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸(코어와 동일 사유).
/**
* 게시판·이커머스 알림 설정 알림톡 탭 연동 UI 구조 검증 (이슈 #28 후속)
*
* 배경: notification_tab_core.json(target_layout=admin_settings) + notification_row_footer.json
* (extension_point, 전역 매칭)은 코어 알림 설정 화면에만 연동 버튼·배너를 노출했다.
* extension_point 는 이름만 같으면 어느 레이아웃에서도 매칭되지만, target_id 기반 overlay
* injection 은 레이아웃별로 독립이라 게시판·이커머스는 배너/안내박스/연결모달이 뜨지 않았다.
*
* notification_tab_board.json / notification_tab_ecommerce.json 을 신설해
* target_layout=admin_board_settings / admin_ecommerce_settings 로 각각 등록했다.
* notification_row_footer.json 은 그대로(전역 매칭)이므로 재사용된다.
*/
import { describe, it, expect } from 'vitest';
import boardOverlay from '../../../extensions/notification_tab_board.json';
import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, type AnyNode } from './helpers';
type OverlayFixture = {
label: string;
overlay: typeof boardOverlay;
targetLayout: string;
targetId: string;
bannerId: string;
notReadyId: string;
testModeId: string;
guideId: string;
modalBodyId: string;
};
const FIXTURES: OverlayFixture[] = [
{
label: '게시판',
overlay: boardOverlay,
targetLayout: 'admin_board_settings',
targetId: 'board_notif_channel_content',
bannerId: 'bizppurio_board_status_banner',
notReadyId: 'bizppurio_board_banner_not_ready',
testModeId: 'bizppurio_board_banner_test_mode',
guideId: 'bizppurio_board_alimtalk_guide',
modalBodyId: 'bizppurio_board_binding_modal_body',
},
{
label: '이커머스',
overlay: ecommerceOverlay,
targetLayout: 'admin_ecommerce_settings',
targetId: 'ecommerce_notif_channel_content',
bannerId: 'bizppurio_ecommerce_status_banner',
notReadyId: 'bizppurio_ecommerce_banner_not_ready',
testModeId: 'bizppurio_ecommerce_banner_test_mode',
guideId: 'bizppurio_ecommerce_alimtalk_guide',
modalBodyId: 'bizppurio_ecommerce_binding_modal_body',
},
];
const collectPluginKeys = (json: unknown): string[] => {
const text = JSON.stringify(json);
const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? [];
return Array.from(new Set([
...prefixed.map((m) => m.replace('$t:', '')),
...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
]));
};
describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({
overlay, targetLayout, targetId, bannerId, notReadyId, testModeId, guideId, modalBodyId,
}) => {
const injectionRoot = {
children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? [])
.flatMap((i) => i.components ?? []),
} as AnyNode;
const modalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => {
expect((overlay as { target_layout?: string }).target_layout).toBe(targetLayout);
expect((overlay as Record<string, unknown>).extension_point).toBeUndefined();
});
it(`배너·안내박스는 target_id=${targetId} 에 prepend_child 로 주입된다`, () => {
const injections = (overlay as { injections?: Array<Record<string, unknown>> }).injections ?? [];
expect(injections).toHaveLength(1);
expect(injections[0].target_id).toBe(targetId);
expect(injections[0].position).toBe('prepend_child');
});
it('연결 맵·승인 템플릿 데이터소스를 등록한다(코어와 동일 endpoint)', () => {
const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
expect(ids).toContain('bizppurioBindings');
expect(ids).toContain('bizppurioApprovedTemplates');
});
it('상태 배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
const banner = findById(injectionRoot, bannerId);
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).toContain("'sms'");
expect(cond).toContain("'alimtalk'");
expect(cond).toContain('readiness?.ready === false');
expect(cond).toContain('is_test_mode === true');
});
it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
const raw = JSON.stringify(findById(injectionRoot, notReadyId));
expect(raw).toContain('banner.not_ready');
expect(raw).toContain('banner.setup_action');
expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings');
});
it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다', () => {
const banner = findById(injectionRoot, testModeId);
expect(banner).toBeTruthy();
const cond = (banner as { if?: string }).if ?? '';
expect(cond).not.toContain('readiness');
expect(cond).toContain('is_test_mode === true');
});
it('알림톡 탭 상시 안내 박스가 있다', () => {
const guide = findById(injectionRoot, guideId);
expect(guide).toBeTruthy();
expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
expect(JSON.stringify(guide)).toContain('binding.list_guide');
});
it('연결 전용 모달(modal_bizppurio_binding)이 modals 로 등록된다', () => {
const modal = findById(modalRoot, 'modal_bizppurio_binding');
expect(modal).toBeTruthy();
expect((modal as { name?: string }).name).toBe('Modal');
});
it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
const modal = findById(modalRoot, 'modal_bizppurio_binding');
const raw = JSON.stringify(modal);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('binding.saved');
expect(raw).toContain('"closeModal"');
expect(raw).toContain('bizppurioBindings');
});
it('연결 템플릿이 없으면 SMS 대체 토글이 비활성이다(코어와 동일 규칙)', () => {
const raw = JSON.stringify(findById(modalRoot, modalBodyId));
expect(raw).toContain('"disabled"');
expect(raw).toContain("=== ''");
});
it('overlay 는 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
const raw = JSON.stringify(overlay);
expect(raw).not.toContain('/api/admin/notification-templates/');
expect(raw).not.toContain('notification_template_form_modal');
});
it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
const keys = collectPluginKeys(overlay);
expect(keys.length).toBeGreaterThan(0);
const resolve = (root: unknown, path: string): unknown =>
path.split('.').slice(1).reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], root);
for (const key of Array.from(new Set(keys))) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
}
});
});
@@ -0,0 +1,291 @@
/**
* 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§6-1)
*
* 3 섹션:
* 1. section_api (연동 환경 + 비즈뿌리오 아이디 + 비밀번호 + API 키)
* 2. section_sending (발신번호 + 알림톡 발신프로필 키)
* 3. section_integration (webhook 수신 주소 안내)
*
* 크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹, 저장은
* 자동바인딩(_local.form) → 코어 /api/admin/plugins/{id}/settings PUT.
*/
import { describe, it, expect } from 'vitest';
import layout from '../../../layouts/admin/plugin_settings.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import {
findById,
findInputByName,
collectHandlers,
collectI18nKeys,
type AnyNode,
} from './helpers';
const root = layout as unknown as AnyNode;
describe('plugin_settings.json — 레이아웃 메타/권한', () => {
it('layout_name 이 plugin_settings 이다', () => {
expect((root as { layout_name?: string }).layout_name).toBe('plugin_settings');
});
it('_admin_base 를 상속한다', () => {
expect((root as { extends?: string }).extends).toBe('_admin_base');
});
it('core.plugins.update 권한을 요구한다', () => {
expect((root as { permissions?: string[] }).permissions).toContain('core.plugins.update');
});
it('settings 데이터소스가 코어 플러그인 설정 API 를 조회한다', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const settings = sources.find((s) => s.id === 'settings');
expect(settings).toBeTruthy();
expect(settings?.endpoint).toBe('/api/admin/plugins/{{route.identifier}}/settings');
expect(settings?.initLocal).toBe('form');
});
});
describe('plugin_settings.json — 자동바인딩', () => {
it('환경설정 탭 패널이 dataKey=form + trackChanges 로 자동바인딩한다', () => {
const container = findById(root, 'connection_tab_panel');
expect(container).toBeTruthy();
expect((container as { dataKey?: string }).dataKey).toBe('form');
expect((container as { trackChanges?: boolean }).trackChanges).toBe(true);
});
});
describe('plugin_settings.json — 섹션', () => {
it.each([
['preparation_notice', '사용 전 준비 안내(상단)'],
['section_api', 'API 연동'],
['section_sending', '발송 설정'],
['report_section', '리포트 수신 설정'],
])('%s 섹션이 존재한다', (id) => {
expect(findById(root, id)).toBeTruthy();
});
it('준비 안내 박스는 총괄 안내(intro)와 콘솔 링크를 구분선 위에 먼저 노출한다', () => {
const notice = findById(root, 'preparation_notice');
const raw = JSON.stringify(notice);
// 총괄 안내 문구 키 + 콘솔 링크가 존재한다
expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.intro');
expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.console_link');
// 총괄 안내가 채널별 준비 목록(sms_label)보다 먼저 배치된다 (이미지1 구조)
const introIdx = raw.indexOf('preparation.intro');
const smsIdx = raw.indexOf('preparation.sms_label');
expect(introIdx).toBeGreaterThanOrEqual(0);
expect(smsIdx).toBeGreaterThanOrEqual(0);
expect(introIdx).toBeLessThan(smsIdx);
});
it('채널별 준비 목록은 PC(lg 이상) 2단, 모바일 1단 그리드로 배치된다', () => {
// 문자·카카오 블록을 감싼 컨테이너가 grid grid-cols-1 lg:grid-cols-2 여야 한다.
// sms_label 을 담은 블록의 부모(구분선 아래 컨테이너)에서 grid 클래스를 확인.
const notice = findById(root, 'preparation_notice');
const raw = JSON.stringify(notice);
// 세로 1단 고정(flex-col) 이 아니라 반응형 grid 여야 한다.
expect(raw).toContain('grid-cols-1');
expect(raw).toContain('lg:grid-cols-2');
});
it('문자·카카오 준비 블록은 카드(배경+테두리)로 감싸고 채널 아이콘을 라벨에 붙인다', () => {
const notice = findById(root, 'preparation_notice');
const raw = JSON.stringify(notice);
// 각 열이 카드로 감싸짐 (다크모드 쌍 포함 solid 배경)
expect(raw).toContain('bg-blue-100');
expect(raw).toContain('dark:bg-blue-900');
// 채널별 아이콘 (문자=envelope, 카카오=comment-dots)
expect(raw).toContain('"envelope"');
expect(raw).toContain('"comment-dots"');
});
it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => {
const card = findById(root, 'test_mode_card');
expect(card).toBeTruthy();
const raw = JSON.stringify(card);
expect(raw).toContain('"Toggle"');
expect(raw).toContain('is_test_mode');
});
it('검수 모드 카드에 검수/운영 계정 분리 권장 안내가 상시 노출된다', () => {
// 검수 켜짐/꺼짐과 무관하게(if 조건 없이) 카드 안에 계정 분리 권장 문구가 있어야 한다.
const card = findById(root, 'test_mode_card') as { if?: string } | null;
expect(card).toBeTruthy();
expect(card?.if).toBeUndefined(); // 카드 자체가 조건부가 아님 → 상시 노출
const raw = JSON.stringify(card);
expect(raw).toContain('settings.test_mode.account_notice');
});
it('운영 모드(검수 off) 경고 박스가 조건부로 존재한다', () => {
const warning = findById(root, 'live_mode_warning');
expect(warning).toBeTruthy();
expect((warning as { if?: string }).if).toContain('!_local.form.is_test_mode');
});
});
describe('plugin_settings.json — 입력 필드 6종', () => {
it.each([
'bizppurio_id',
'password',
'api_key',
'sender_number',
'sender_key',
])('%s 입력 필드가 자동바인딩 name 으로 존재한다', (name) => {
expect(findInputByName(root, name)).toBeTruthy();
});
it('크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹된다', () => {
for (const cred of ['password', 'api_key', 'sender_key']) {
const input = findInputByName(root, cred);
expect((input?.props as { type?: string } | undefined)?.type).toBe('password');
}
});
it('bizppurio_id/sender_number 는 일반 text 입력이다', () => {
for (const field of ['bizppurio_id', 'sender_number']) {
const input = findInputByName(root, field);
expect((input?.props as { type?: string } | undefined)?.type).toBe('text');
}
});
});
describe('plugin_settings.json — 리포트 수신 설정', () => {
it('report_url 데이터소스가 조회 엔드포인트를 호출한다', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const reportUrl = sources.find((s) => s.id === 'report_url');
expect(reportUrl).toBeTruthy();
expect(reportUrl?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/report-url');
});
it('리포트 섹션에 조회값(fallback 웹훅 경로) readonly 표시 + 복사 버튼이 있다', () => {
const section = findById(root, 'report_section');
const raw = JSON.stringify(section);
expect(raw).toContain('report_url?.data?.url');
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/webhook');
expect(raw).toContain('"readOnly":true');
expect(raw).toContain('copyToClipboard');
});
});
describe('plugin_settings.json — 필드 인라인 에러', () => {
it.each([
'bizppurio_id',
'password',
'api_key',
'sender_number',
'sender_key',
])('%s 필드에 인라인 에러 노드가 존재한다', (name) => {
const errorNode = findById(root, `field_${name}_error`);
expect(errorNode).toBeTruthy();
expect((errorNode as { if?: string }).if).toContain(`_local.errors?.${name}`);
});
});
describe('plugin_settings.json — 저장 버튼', () => {
it('저장 버튼이 hasChanges 없으면 비활성화된다', () => {
const save = findById(root, 'save_button');
expect((save?.props as { disabled?: string } | undefined)?.disabled).toContain('!_local.hasChanges');
});
it('저장은 코어 설정 API 로 PUT 한다', () => {
const text = JSON.stringify(findById(root, 'save_button'));
expect(text).toContain('/api/admin/plugins/{{route.identifier}}/settings');
expect(text).toContain('"method":"PUT"');
expect(text).toContain('{{_local.form}}');
});
it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => {
const handlers = collectHandlers(layout);
const allowed = [
'apiCall', 'setState', 'toast', 'navigate', 'sequence', 'switch',
'refetchDataSource', 'scrollIntoView', 'copyToClipboard',
'openModal', 'closeModal', 'replaceUrl',
'sirsoft-message_bizppurio.uploadTemplateImage',
];
for (const h of handlers) {
expect(allowed).toContain(h);
}
});
});
describe('plugin_settings.json — i18n 키 정합', () => {
it('레이아웃이 참조하는 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => {
const keys = collectI18nKeys(layout);
expect(keys.length).toBeGreaterThan(0);
const resolve = (dict: Record<string, unknown>, path: string): unknown =>
path.split('.').reduce<unknown>((acc, seg) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[seg];
}
return undefined;
}, dict);
for (const raw of keys) {
// "$t:sirsoft-message_bizppurio.settings.title" → "settings.title"
const path = raw.replace('$t:sirsoft-message_bizppurio.', '');
expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy();
expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy();
}
});
});
describe('plugin_settings.json — 탭 전환', () => {
// 탭 버튼은 query.tab 을 바꾸며 화면(if 조건부 패널)을 다시 그려야 하므로
// replaceUrl(URL만 변경, if 재평가 없음) 이 아니라 navigate 를 써야 한다.
const tabButtonHandlers = (id: string): string[] => {
const btn = findById(layout, id);
const handlers: string[] = [];
const walk = (node: unknown): void => {
if (!node || typeof node !== 'object') return;
const n = node as Record<string, unknown>;
if (typeof n.handler === 'string') handlers.push(n.handler);
for (const v of Object.values(n)) {
if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === 'object') walk(v);
}
};
walk((btn as { actions?: unknown })?.actions);
return handlers;
};
it('환경설정 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => {
const handlers = tabButtonHandlers('tab_connection');
expect(handlers).toContain('navigate');
expect(handlers).not.toContain('replaceUrl');
});
it('알림톡 템플릿 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => {
const handlers = tabButtonHandlers('tab_templates');
expect(handlers).toContain('navigate');
expect(handlers).not.toContain('replaceUrl');
});
});
describe('plugin_settings.json — 목록 조회 실패 표시', () => {
it('alimtalk_templates 데이터소스가 실패 사유를 _local.templateListError 에 담는다', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const ds = sources.find((s) => s.id === 'alimtalk_templates') as
| Record<string, unknown>
| undefined;
expect(ds).toBeTruthy();
const onError = JSON.stringify(ds?.onError ?? {});
// 카카오가 준 사유(kakao_message)를 우선 노출, 없으면 error.message 폴백
expect(onError).toContain('templateListError');
expect(onError).toContain('kakao_message');
});
it('목록 오류 배너가 오류 존재 + 준비완료(ready) 조건으로 존재한다', () => {
const banner = findById(layout, 'templates_list_error') as
| Record<string, unknown>
| undefined;
expect(banner).toBeTruthy();
// 키 미설정(ready=false)일 때는 readiness 안내 배너가 담당 → 빨간 오류 배너는 숨겨
// 두 배너가 동시에 뜨지 않게 한다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우만 노출.
expect(banner?.if).toBe('{{_local.templateListError && templates_readiness?.data?.ready}}');
// 사유 본문(카카오 실제 사유)을 그대로 렌더한다
expect(JSON.stringify(banner)).toContain('{{_local.templateListError}}');
});
});
@@ -0,0 +1,25 @@
/**
* 비즈뿌리오 플러그인 레이아웃 렌더 테스트 환경 설정.
*
* jest-dom matcher(toBeInTheDocument 등)를 로드하고, jsdom 전역 mock 을 준비한다.
* 코어 레이아웃 렌더 테스트(createLayoutTest)를 사용하는 테스트가 소비한다.
*/
import '@testing-library/jest-dom';
import { vi } from 'vitest';
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
@@ -0,0 +1,11 @@
/**
* sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.
*
* 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인
* 식별자가 네임스페이스로 접두된다.
*
* 현재 등록 핸들러 없음(알림톡 템플릿 등록·이미지 업로드 제거로 uploadTemplateImage 제거).
* 커스텀 핸들러 추가 시 이 맵에 등록한다.
*/
export const handlerMap = {} as const;
@@ -0,0 +1,95 @@
/**
* sirsoft-message_bizppurio 플러그인 엔트리포인트.
*
* 플러그인 활성화 시 자동 로드되어 handlerMap 의 커스텀 핸들러를 ActionDispatcher 에
* 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.
* (현재 등록 핸들러 없음 — 필요 시 handlers/index.ts 에 추가.)
*/
import { handlerMap } from './handlers';
const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
const logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {
log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
};
/**
* handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다.
*
* @param dispatcher 코어 ActionDispatcher 인스턴스
*/
function register(dispatcher: any): void {
Object.entries(handlerMap).forEach(([name, handler]) => {
dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
});
logger.log(
`${Object.keys(handlerMap).length} handler(s) registered:`,
Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`),
);
}
/**
* ActionDispatcher 준비 후 핸들러를 등록합니다.
*
* 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다.
*
* @param retry ActionDispatcher 부재 시 재시도 여부
*/
function registerHandlers(retry: boolean): void {
const dispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (dispatcher) {
register(dispatcher);
return;
}
if (!retry) {
logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.');
return;
}
let count = 0;
const max = 50; // 최대 5초 (50 * 100ms)
const tick = () => {
const found = (window as any).G7Core?.getActionDispatcher?.();
if (found) {
register(found);
return;
}
if (++count <= max) {
setTimeout(tick, 100);
} else {
logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.');
}
};
tick();
}
/**
* 플러그인 초기화 — DOM 준비 후 핸들러 등록.
*/
export function initPlugin(): void {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => registerHandlers(true));
} else {
const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();
registerHandlers(!hasDispatcher);
}
}
initPlugin();
if (typeof window !== 'undefined') {
(window as any).__SirsoftMessageBizppurio = {
identifier: PLUGIN_IDENTIFIER,
handlers: Object.keys(handlerMap),
initPlugin,
};
}
@@ -0,0 +1,28 @@
/**
* sirsoft-message_bizppurio 플러그인 프론트 타입 정의.
*/
/**
* 액션 컨텍스트 인터페이스.
*
* ActionDispatcher 가 커스텀 핸들러 실행 시 전달하는 컨텍스트다.
*/
export interface ActionContext {
/** 현재 로컬 상태 가져오기 */
getLocalState?: () => Record<string, any>;
/** 로컬 상태 업데이트 */
setLocalState?: (updates: Record<string, any>) => void;
/** 이벤트 객체 */
event?: Event;
/** 데이터 컨텍스트 */
dataContext?: Record<string, any>;
}
/**
* 커스텀 핸들러 액션 객체(공통).
*/
export interface ActionWithParams {
handler: string;
params?: Record<string, any>;
[key: string]: any;
}
@@ -0,0 +1,218 @@
{
"name": "Bizppurio Messaging",
"description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin.",
"settings": {
"title": "Bizppurio Messaging Settings",
"description": "Manage Bizppurio integration credentials and sending options.",
"save": "Save",
"saving": "Saving...",
"save_success": "Settings saved.",
"save_failed": "Failed to save settings.",
"test_mode": {
"label": "Inspection Mode",
"hint": "In inspection mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.",
"account_notice": "We recommend using separate Bizppurio accounts for inspection and production."
},
"live_mode_warning_title": "Sending in production",
"live_mode_warning_body": "Inspection mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep inspection mode on during inspection.",
"sections": {
"api": {
"title": "API Integration",
"description": "Credentials shared by the sending system and the Kakao management system."
},
"sending": {
"title": "Sending Options",
"description": "Sender information used for SMS and alimtalk delivery."
}
},
"fields": {
"bizppurio_id": {
"label": "Bizppurio ID",
"hint": "The Bizppurio account ID used for both sending and Kakao management."
},
"password": {
"label": "Password",
"hint": "The Bizppurio password used to issue the sending token."
},
"api_key": {
"label": "API Key",
"hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support."
},
"sender_number": {
"label": "Sender Number",
"hint": "The sender phone number used for SMS and alimtalk delivery."
},
"sender_key": {
"label": "Alimtalk Sender Profile Key",
"hint": "The 40-character sender profile key used for alimtalk delivery and template lookup."
},
"template_cache_minutes": {
"label": "Alimtalk content cache (minutes)",
"hint": "Reuses Kakao template content for this period. 0 = always latest (not recommended for high volume).",
"clear_cache": "Clear cache",
"clear_cache_hint": "If you edited a template in Kakao, click to apply it immediately.",
"clear_cache_success": "Template content cache cleared. The latest content will apply from the next dispatch.",
"clear_cache_failed": "Failed to clear the cache."
}
},
"report": {
"section_title": "Report Endpoint",
"hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).",
"note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.",
"copy": "Copy",
"copied": "Report endpoint copied to clipboard."
},
"cache": {
"section_title": "Alimtalk dispatch content cache",
"section_description": "When sending alimtalk, the template content (body and buttons) registered in Kakao must be sent as-is. To avoid requesting the content from Kakao on every dispatch, the fetched content is reused (cached) for a set period. This keeps sending fast and avoids hitting Kakao's lookup rate limit even under high volume."
},
"tabs": {
"connection": "Settings",
"templates": "Alimtalk Templates"
},
"preparation": {
"intro": "To send SMS or Kakao Alimtalk messages, first complete the preparations below in the Bizppurio console.",
"sms_label": "SMS/LMS",
"sms_sender": "Register your sender number.",
"kakao_label": "Kakao Alimtalk",
"kakao_channel": "Create a KakaoTalk business channel and register a sender profile.",
"kakao_template": "Register your Alimtalk templates and get them approved.",
"kakao_apikey": "Request an API key from Bizppurio support.",
"console_link": "Open Bizppurio console"
}
},
"binding": {
"section_title": "KakaoTalk Alimtalk Binding",
"list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.",
"section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.",
"modal_title": "Alimtalk Binding · {name}",
"unbound": "Not connected",
"unavailable": "Unavailable — reconnect needed",
"btn_connect": "Connect",
"btn_change": "Change",
"fallback_on": "SMS fallback ON",
"fallback_off": "SMS fallback OFF",
"connected_template": "Connected Template",
"none": "Not connected",
"no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.",
"templates_load_failed": "Failed to load the template list. Check the plugin settings (credentials).",
"fallback_sms": "Fall back to SMS on failure",
"fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.",
"variables_hint": "Available variables (auto-substituted on send)",
"saved": "Alimtalk binding saved.",
"save_error": "Failed to save alimtalk binding."
},
"banner": {
"not_ready": "Setup required for delivery is incomplete.",
"setup_action": "Set up",
"test_mode": "Inspection mode — messages are not actually sent."
},
"dispatch_result": {
"column_header": "SMS/Alimtalk Result",
"inspection_label": "Inspection",
"low_balance": "Low balance",
"fallback": "SMS fallback {status}",
"detail_title": "SMS/Alimtalk delivery result",
"channel_label": "Channel: {channel}",
"sent_content_label": "Actual sent content",
"sent_content_hint": "Alimtalk messages are sent using the actual content of the approved Kakao template, which may differ from the \"Body\" above."
},
"editor": {
"data_source": {
"dispatch_results": "Bizppurio dispatch results"
}
},
"templates": {
"title": "Alimtalk Templates",
"description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.",
"readiness": {
"title": "The following settings are required to use alimtalk templates",
"go_settings": "Go to settings",
"missing_label": "Missing",
"api_key_missing": "Kakao management API key",
"sender_key_missing": "Alimtalk sender profile key",
"note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off inspection mode and using templates approved by Kakao."
},
"list_error": {
"title": "Failed to load the template list"
},
"list_notice": {
"console_desc": "Register, edit, and review templates in the Bizppurio console.",
"console_link": "Open Bizppurio console"
},
"list": {
"refresh": "Refresh",
"search": "Search",
"search_placeholder": "Search by name (2-50 chars)",
"filter_all": "All statuses",
"empty": "No alimtalk templates registered.",
"empty_hint": "Templates registered in the Bizppurio console appear here.",
"load_failed": "Failed to load templates. Check the sender profile key and API key.",
"columns": {
"no": "No.",
"name": "Name",
"code": "Code",
"status": "Status",
"requested_at": "Requested",
"processed_at": "Processed",
"actions": "Content"
}
},
"status": {
"sendable": "Sendable",
"inspecting": "Inspecting",
"rejected": "Rejected",
"uninspected": "Not inspected",
"stopped": "Stopped",
"blocked": "Blocked",
"dormant": "Dormant",
"unknown": "Unknown"
},
"status_sub": {
"rdy": "(unused)"
},
"status_guide": {
"title": "Status badges",
"sendable_label": "Sendable",
"sendable": "Approved. Only this status can be linked to notifications and sent.",
"inspecting_label": "Inspecting",
"inspecting": "Kakao review in progress (2-3 business days).",
"pending_label": "Uninspected / Rejected",
"pending": "Cannot be sent yet. Request review or edit in the console."
},
"link_type": {
"WL": "Web link",
"AL": "App link",
"DS": "Delivery tracking",
"BK": "Bot keyword",
"MD": "Message delivery",
"AC": "Add channel",
"BC": "Consultation talk",
"BT": "Bot transfer",
"TN": "Call",
"MP": "Map",
"P1": "Secure image send",
"P2": "Privacy consent",
"P3": "One-click pay"
},
"actions": {
"detail": "Detail"
},
"detail": {
"title": "Template detail",
"close": "Close",
"buttons": "Buttons",
"extra": "Additional info",
"category": "Category",
"code": "Template code",
"content": "Template content",
"emphasize_type": "Template type",
"image_upload": "Attach image",
"subtitle_field": "Highlight subtitle",
"title_field": "Highlight title",
"type_image": "Image",
"type_none": "Basic",
"type_text": "Highlighted"
}
}
}
@@ -0,0 +1,218 @@
{
"name": "비즈뿌리오 메시지 발송",
"description": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다.",
"settings": {
"title": "비즈뿌리오 메시지 발송 설정",
"description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.",
"save": "저장",
"saving": "저장 중...",
"save_success": "설정이 저장되었습니다.",
"save_failed": "설정 저장에 실패했습니다.",
"test_mode": {
"label": "검수 모드",
"hint": "검수 모드에서는 비즈뿌리오 검수 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다.",
"account_notice": "검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다."
},
"live_mode_warning_title": "운영 환경으로 발송됩니다",
"live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.",
"sections": {
"api": {
"title": "API 연동",
"description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다."
},
"sending": {
"title": "발송 설정",
"description": "문자·알림톡 발송에 사용하는 발신 정보입니다."
}
},
"fields": {
"bizppurio_id": {
"label": "비즈뿌리오 아이디",
"hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다."
},
"password": {
"label": "비밀번호",
"hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다."
},
"api_key": {
"label": "API 키",
"hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다."
},
"sender_number": {
"label": "발신번호",
"hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다."
},
"sender_key": {
"label": "알림톡 발신프로필 키",
"hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다."
},
"template_cache_minutes": {
"label": "알림톡 내용 캐시 시간(분)",
"hint": "카카오 템플릿 내용을 이 시간만큼 재사용합니다. 0 = 매번 최신(발송 많으면 비권장).",
"clear_cache": "캐시 초기화",
"clear_cache_hint": "카카오에서 템플릿을 수정했다면 눌러서 즉시 반영하세요.",
"clear_cache_success": "템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
"clear_cache_failed": "캐시 초기화에 실패했습니다."
}
},
"report": {
"section_title": "리포트 수신 설정",
"hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.",
"note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.",
"copy": "복사",
"copied": "리포트 수신 주소가 클립보드에 복사되었습니다."
},
"cache": {
"section_title": "알림톡 발송 내용 캐시",
"section_description": "알림톡은 발송할 때 카카오에 등록된 템플릿 내용(본문·버튼)을 그대로 보내야 합니다. 발송할 때마다 카카오에 내용을 요청하지 않도록, 한 번 가져온 내용을 일정 시간 재사용(캐시)합니다. 덕분에 발송이 많아도 카카오 조회 제한에 걸리지 않고 빠르게 발송됩니다."
},
"tabs": {
"connection": "환경설정",
"templates": "알림톡 템플릿"
},
"preparation": {
"intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.",
"sms_label": "문자(SMS/LMS)",
"sms_sender": "발신번호를 등록하세요.",
"kakao_label": "카카오 알림톡",
"kakao_channel": "카카오톡 비즈니스 채널을 만들고 발신프로필을 등록하세요.",
"kakao_template": "알림톡 템플릿을 등록하고 승인받으세요.",
"kakao_apikey": "고객센터에 API 키를 요청하세요.",
"console_link": "비즈뿌리오 콘솔 열기"
}
},
"binding": {
"section_title": "카카오 알림톡 연동",
"list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.",
"section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.",
"modal_title": "알림톡 연결 · {name}",
"unbound": "미연결",
"unavailable": "사용 불가 — 재연결 필요",
"btn_connect": "연결",
"btn_change": "연결 변경",
"fallback_on": "SMS 대체 ON",
"fallback_off": "SMS 대체 OFF",
"connected_template": "연결 템플릿",
"none": "연결 안 함",
"no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.",
"templates_load_failed": "템플릿 목록을 불러오지 못했습니다. 플러그인 설정(자격증명)을 확인하세요.",
"fallback_sms": "실패 시 SMS로 대체발송",
"fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.",
"variables_hint": "제공 변수 (발송 시 자동 치환)",
"saved": "알림톡 연동을 저장했습니다.",
"save_error": "알림톡 연동 저장에 실패했습니다."
},
"banner": {
"not_ready": "발송에 필요한 설정이 완료되지 않았습니다.",
"setup_action": "설정하기",
"test_mode": "검수 모드입니다 — 실제 발송은 이뤄지지 않습니다."
},
"dispatch_result": {
"column_header": "문자·알림톡 결과",
"inspection_label": "검수",
"low_balance": "잔액 부족",
"fallback": "SMS 대체발송 {status}",
"detail_title": "문자·알림톡 발송 결과",
"channel_label": "발송 채널: {channel}",
"sent_content_label": "실제 발송 내용",
"sent_content_hint": "알림톡은 카카오 승인 템플릿의 실제 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다."
},
"editor": {
"data_source": {
"dispatch_results": "비즈뿌리오 발송 결과"
}
},
"templates": {
"title": "알림톡 템플릿",
"description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.",
"readiness": {
"title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다",
"go_settings": "환경설정으로 이동",
"missing_label": "미설정 항목",
"api_key_missing": "카카오 관리 API 키",
"sender_key_missing": "알림톡 발신프로필 키",
"note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다."
},
"list_error": {
"title": "템플릿 목록을 불러오지 못했습니다"
},
"list_notice": {
"console_desc": "템플릿 등록·수정·검수는 비즈뿌리오 콘솔에서 진행해주세요.",
"console_link": "비즈뿌리오 콘솔 열기"
},
"list": {
"refresh": "새로고침",
"search": "검색",
"search_placeholder": "템플릿명 검색 (2~50자)",
"filter_all": "전체 상태",
"empty": "등록된 알림톡 템플릿이 없습니다.",
"empty_hint": "비즈뿌리오 콘솔에서 등록한 템플릿이 여기에 표시됩니다.",
"load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.",
"columns": {
"no": "번호",
"name": "템플릿명",
"code": "코드",
"status": "상태",
"requested_at": "등록요청일",
"processed_at": "처리일",
"actions": "내용"
}
},
"status": {
"sendable": "발송가능",
"inspecting": "검수중",
"rejected": "반려",
"uninspected": "미검수",
"stopped": "중지",
"blocked": "차단",
"dormant": "휴면",
"unknown": "알 수 없음"
},
"status_sub": {
"rdy": "(사용전)"
},
"status_guide": {
"title": "상태 배지",
"sendable_label": "발송가능",
"sendable": "승인 완료. 이 상태만 알림에 연결·발송할 수 있습니다.",
"inspecting_label": "검수중",
"inspecting": "카카오 검수 진행 중 (영업일 2~3일).",
"pending_label": "미검수·반려",
"pending": "아직 발송할 수 없습니다. 콘솔에서 검수요청·수정하세요."
},
"link_type": {
"WL": "웹링크",
"AL": "앱링크",
"DS": "배송조회",
"BK": "봇키워드",
"MD": "메시지전달",
"AC": "채널추가",
"BC": "상담톡전환",
"BT": "봇전환",
"TN": "전화하기",
"MP": "지도보기",
"P1": "이미지 보안전송",
"P2": "개인정보이용",
"P3": "원클릭 결제"
},
"actions": {
"detail": "상세"
},
"detail": {
"title": "템플릿 상세",
"close": "닫기",
"buttons": "버튼",
"extra": "부가정보",
"category": "카테고리",
"code": "템플릿 코드",
"content": "템플릿 내용",
"emphasize_type": "템플릿 유형",
"image_upload": "이미지 첨부",
"subtitle_field": "보조 문구",
"title_field": "강조표기 문구",
"type_image": "이미지형",
"type_none": "기본형",
"type_text": "강조표기형"
}
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Concerns;
use App\Helpers\ResponseHelper;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 카카오 관리 API(kapi) 위임 액션을 공통 래핑하는 트레이트.
*
* kapi 실패(자격증명 미설정·반려·차단 등)는 BizppurioApiException 으로 전달되므로, 이를 catch
* 하여 카카오가 준 실패 사유(message)와 결과 코드를 그대로 422 로 반환한다. 운영자가 반려/차단
* 사유를 화면에서 바로 확인할 수 있게 한다. 알림톡 템플릿 관리(Phase 5)와 연동(Phase 6)
* 컨트롤러가 공유한다.
*/
trait GuardsKakaoRequests
{
/**
* kapi 위임 콜백을 실행하고 실패 시 카카오 사유를 422 로 반환합니다.
*
* @param callable():JsonResponse $callback kapi 위임 액션
* @return JsonResponse 성공 응답 또는 카카오 사유가 담긴 422
*/
private function guard(callable $callback): JsonResponse
{
try {
return $callback();
} catch (BizppurioApiException $e) {
return ResponseHelper::error(
'sirsoft-message_bizppurio::messages.error.kakao_request_failed',
422,
[
'kakao_message' => $e->getMessage(),
'result_code' => $e->getResultCode(),
],
);
}
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Concerns;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
/**
* webhook replay 방어 — 동일 발송 이력에 리포트가 중복 도착하면 멱등 처리한다.
*
* 비즈뿌리오 URL PUSH 는 동일 리포트를 재전송할 수 있다. 발송 이력의 `reported_at`
* 이 이미 채워져 있으면 리포트를 이미 반영한 것이므로, 상태를 다시 뒤집거나 잔액부족
* 자체 알림을 재발송하지 않도록 조기 리턴한다(계획서 D13).
*/
trait PreventsReplayWebhook
{
/**
* 이 이력이 이미 리포트를 반영했는지(=replay) 확인합니다.
*
* @param BizppurioDispatch $dispatch refkey 로 매칭된 발송 이력
* @return bool true 면 중복 리포트 — 멱등 응답으로 처리해야 함
*/
protected function wasAlreadyReported(BizppurioDispatch $dispatch): bool
{
return $dispatch->reported_at !== null;
}
/**
* replay 감지를 통일된 형식으로 로깅합니다 (운영 모니터링용).
*
* @param string $refkey 우리 부여 키
* @param string|null $resultCode 리포트 결과 코드
*/
protected function logReplayDetected(string $refkey, ?string $resultCode): void
{
Log::info('비즈뿌리오 webhook: replay 감지 — 이미 리포트 반영됨, 멱등 응답', [
'refkey' => $refkey,
'result_code' => $resultCode,
]);
}
}
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
/**
* 알림톡 템플릿 조회 컨트롤러 (Phase 5).
*
* 카카오 관리 API(kapi)로 알림톡 템플릿을 실시간 조회한다(목록·상세·카테고리·발신프로필).
* 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임하며, 이 화면은 조회 + 알림 연결만
* 담당한다. 템플릿은 DB 에 저장하지 않고 목록/상세를 매 요청 실시간 조회한다.
*
* 권한(라우트 미들웨어):
* - 조회(list/detail/categories/profiles): sirsoft-message_bizppurio.messaging.view
*
* kapi 실패는 BizppurioApiException 으로 전달되므로, 각 액션에서 catch 하여 카카오가 준
* 실패 사유(message)를 그대로 422 로 반환한다(운영자가 조회 실패 원인을 바로 확인).
*/
class AlimtalkTemplateController extends AdminBaseController
{
use GuardsKakaoRequests;
/**
* @param AlimtalkTemplateService $service 알림톡 템플릿 서비스
* @param NotificationBindingService $bindings 연동 서비스(발송 내용 캐시 초기화 위임)
*/
public function __construct(
private readonly AlimtalkTemplateService $service,
private readonly NotificationBindingService $bindings,
) {
parent::__construct();
}
/**
* 알림톡 템플릿 목록을 실시간 조회합니다.
*
* 쿼리: status(templateStatus)·keyword(2~50자)·page·count
*
* @param Request $request HTTP 요청
* @return JsonResponse data 에 templates·pagination
*/
public function index(Request $request): JsonResponse
{
return $this->guard(function () use ($request) {
$result = $this->service->list([
'status' => $request->query('status'),
'keyword' => $request->query('keyword'),
'page' => $request->query('page'),
'count' => $request->query('count'),
]);
return ResponseHelper::success('messages.success', $result);
});
}
/**
* 알림톡 템플릿 상세를 실시간 조회합니다.
*
* @param string $templateCode 템플릿 코드
* @return JsonResponse data.template 에 배지·가능 액션이 부가된 상세
*/
public function show(string $templateCode): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'template' => $this->service->detail($templateCode),
]));
}
/**
* 템플릿 등록에 사용할 카테고리 전체를 조회합니다.
*
* @return JsonResponse data.categories 에 대분류·소분류 목록
*/
public function categories(): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'categories' => $this->service->categories(),
]));
}
/**
* 발신프로필(사용중) 정보를 조회합니다.
*
* @return JsonResponse data.profiles 에 발신프로필 상태 정보
*/
public function profiles(): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'profiles' => $this->service->senderProfiles(),
]));
}
/**
* 발송용 템플릿 내용 캐시를 초기화합니다 (관리자 수동 갱신).
*
* 카카오에서 템플릿 내용을 방금 변경해 캐시 만료(기본 1시간)를 기다리지 않고 즉시
* 반영하고 싶을 때 호출한다. 연결된 모든 알림톡 템플릿의 캐시를 비워, 다음 발송에서
* 최신 내용으로 재조회되게 한다. kapi 호출 없이 로컬 캐시만 비우므로 rate limit 영향 없음.
*
* @return JsonResponse data.cleared 에 초기화한 캐시 수
*/
public function clearCache(): JsonResponse
{
$cleared = $this->bindings->clearTemplateContentCache();
return ResponseHelper::success('messages.cache.cleared', [
'cleared' => $cleared,
]);
}
// kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환하는 guard() 는
// GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유).
// 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 조회 실패 원인을 즉시 파악한다.
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\DispatchResultLookupRequest;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchResultService;
/**
* 코어 알림 발송 이력 화면용 비즈뿌리오 발송 결과 조회 컨트롤러 (A-2 표시 주입).
*
* 코어 "알림 발송 이력" 화면(sirsoft-admin_basic)에 plugin overlay 로 얹은 결과 컬럼이, 현재
* 페이지의 코어 알림 로그 id 배열을 이 API 로 넘겨 비즈뿌리오 결과(상태·사유·잔액부족·대체발송)를
* 한 번에 조회한다. 코어 화면·코어 앱·코어 테이블은 전혀 건드리지 않는다(연결 표식은 우리 dispatch
* 쪽에만 있고, 이 API 가 그것을 읽어 로그 id 키 맵으로 돌려준다).
*
* 권한(라우트 미들웨어): 조회 = messaging.view.
*/
class DispatchResultController extends AdminBaseController
{
/**
* @param DispatchResultService $service 결과 조회 서비스
*/
public function __construct(
private readonly DispatchResultService $service,
) {
parent::__construct();
}
/**
* 코어 알림 로그 id 배열에 대한 비즈뿌리오 결과 맵을 반환합니다.
*
* @param DispatchResultLookupRequest $request 검증된 로그 id 목록
* @return JsonResponse data.results 에 notification_log_id → 결과 맵
*/
public function lookup(DispatchResultLookupRequest $request): JsonResponse
{
return ResponseHelper::success('messages.success', [
'results' => $this->service->resultsForLogIds($request->logIds()),
]);
}
/**
* 최근 연결된 비즈뿌리오 결과 맵을 반환합니다 (코어 이력 화면 결과 컬럼용, 타이밍 무관).
*
* 파라미터 없이 GET 으로 최근 결과 맵을 받아 화면이 row.id 로 매칭한다(kginicis test-map 선례).
* 목록 data_source(notificationLogs) 로드 순서에 의존하지 않아, 결과 컬럼이 비는 문제를 원천 차단.
*
* @return JsonResponse data.results 에 notification_log_id → 결과 맵
*/
public function recent(): JsonResponse
{
return ResponseHelper::success('messages.success', [
'results' => $this->service->recentResults(),
]);
}
}
@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreNotificationBindingRequest;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
/**
* 알림↔알림톡 템플릿 연동 컨트롤러 (계획서 §6-2, Phase 6 재설계).
*
* 알림 설정 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS
* 대체 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이
* 컨트롤러로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼과 무관해
* 코어 템플릿 무오염). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다.
*
* - index/all: 현재 알림톡 연동 맵(전용 칸 프리필)
* - approvedTemplates: 연결 가능(승인) 템플릿 드롭다운 옵션
* - store: 연결 템플릿·SMS 대체 즉시 저장(빈 코드=해제)
*
* 권한(라우트 미들웨어): 조회 = messaging.view, 저장 = messaging.manage
*/
class NotificationBindingController extends AdminBaseController
{
use GuardsKakaoRequests;
/**
* @param NotificationBindingService $service 연동 서비스
*/
public function __construct(
private readonly NotificationBindingService $service,
) {
parent::__construct();
}
/**
* 알림톡 채널의 현재 연동 맵을 반환합니다 (전용 칸 프리필).
*
* notification_type → 연동 정보(연결 템플릿·SMS 대체) 맵. 전용 칸이 편집 대상 알림의
* type 으로 조회해 드롭다운·토글 초기값을 채운다.
*
* @return JsonResponse data.bindings 에 notification_type 키의 연동 맵
*/
public function index(): JsonResponse
{
// 알림톡 탭 진입 표시용 — 카카오 승인 목록과 대조해 소실(발송 불가) 연동을 함께 표시한다.
// 카카오 조회 실패 시 서비스가 판정을 생략하므로 이 조회가 목록 표시를 막지 않는다.
return ResponseHelper::success('messages.success', [
'bindings' => $this->service->all(withAvailability: true),
]);
}
/**
* 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 드롭다운).
*
* kapi 실패는 카카오가 준 사유를 그대로 422 로 반환한다.
*
* @return JsonResponse data.templates 에 승인 템플릿(code/name) 배열, 실패 시 422
*/
public function approvedTemplates(): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'templates' => $this->service->approvedTemplates(),
]));
}
/**
* 연결 템플릿·SMS 대체를 즉시 저장합니다 (전용 칸 변경 시 자동 호출).
*
* 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다("빈 코드=해제" 규칙 —
* 드롭다운에서 "연결 안 함"을 고르면 해제까지 한 번에 처리). SMS 대체는 연결이 있을 때만
* 의미가 있다.
*
* @param StoreNotificationBindingRequest $request 검증된 연동 입력
* @return JsonResponse 저장 결과 (해제 시에도 200)
*/
public function store(StoreNotificationBindingRequest $request): JsonResponse
{
$validated = $request->validated();
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(),
]);
});
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Controllers;
use App\Helpers\ResponseHelper;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioWebhookRequest;
use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService;
/**
* 비즈뿌리오 webhook(URL PUSH) 리포트 수신 컨트롤러.
*
* 인증은 라우트의 IP 화이트리스트 미들웨어가 담당한다. 검증된 리포트를
* WebhookReportService 에 위임해 상태전이·replay 멱등·잔액부족 알림을 처리한다.
*
* 항상 200 으로 응답한다 — 비즈뿌리오가 실패 응답을 재전송(=중복 리포트)하지 않도록
* 하기 위함이며, 위조/미매칭도 성공 수신으로 흡수한다(replay 멱등이 재처리를 막음).
*/
class BizppurioWebhookController
{
/**
* @param WebhookReportService $reports 리포트 처리 서비스
*/
public function __construct(
private readonly WebhookReportService $reports,
) {}
/**
* webhook 리포트를 수신해 발송 이력 상태를 갱신합니다.
*
* @param BizppurioWebhookRequest $request 검증된 리포트
* @return JsonResponse 항상 200
*/
public function handle(BizppurioWebhookRequest $request): JsonResponse
{
$this->reports->apply([
'REFKEY' => $request->input('REFKEY'),
'RESULT' => $request->input('RESULT'),
'MEDIA' => $request->input('MEDIA'),
'TELRES' => $request->input('TELRES'),
'KAORES' => $request->input('KAORES'),
'raw' => $request->all(),
]);
return ResponseHelper::success('sirsoft-message_bizppurio::messages.webhook.received');
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 발송 채널 — bizppurio_dispatches.channel 컬럼의 값 집합.
*
* SMS/LMS 는 문자, 알림톡은 카카오 채널. 실제 발송 유형(SMS/LMS 자동 분기)은
* SmsTypeResolver(Phase 2)가 byte 판별로 확정한다.
*/
enum DispatchChannel: string
{
case Sms = 'sms';
case Lms = 'lms';
case Alimtalk = 'alimtalk';
/**
* 현재 locale 에 맞는 채널 라벨을 반환합니다.
*
* @return string 다국어 채널 라벨 (예: "SMS", "LMS", "알림톡")
*/
public function label(): string
{
return match ($this) {
self::Sms => __('sirsoft-message_bizppurio::messages.channel.sms'),
self::Lms => __('sirsoft-message_bizppurio::messages.channel.lms'),
self::Alimtalk => __('sirsoft-message_bizppurio::messages.channel.alimtalk'),
};
}
/**
* 문자(SMS/LMS) 채널 여부를 반환합니다.
*
* @return bool 문자 채널이면 true, 알림톡이면 false
*/
public function isText(): bool
{
return $this === self::Sms || $this === self::Lms;
}
/**
* 모든 채널 값 목록을 반환합니다.
*
* @return array<int, string> 채널 문자열 배열 (예: ["sms", "lms", "alimtalk"])
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 발송 출처 — bizppurio_dispatches.source 컬럼의 값 집합.
*
* 1차 범위는 auto(코어 알림 이벤트 자동발송)만 사용한다.
* manual(수동발송)·bulk(대량발송)은 후속(D15).
*/
enum DispatchSource: string
{
case Auto = 'auto';
case Manual = 'manual';
case Bulk = 'bulk';
/**
* 현재 locale 에 맞는 출처 라벨을 반환합니다.
*
* @return string 다국어 출처 라벨 (예: "자동", "수동", "대량")
*/
public function label(): string
{
return match ($this) {
self::Auto => __('sirsoft-message_bizppurio::messages.source.auto'),
self::Manual => __('sirsoft-message_bizppurio::messages.source.manual'),
self::Bulk => __('sirsoft-message_bizppurio::messages.source.bulk'),
};
}
/**
* 모든 출처 값 목록을 반환합니다.
*
* @return array<int, string> 출처 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 발송 상태 — bizppurio_dispatches.status 컬럼의 값 집합.
*
* pending(대기) → sent(발송요청 성공, 리포트 대기) → success/failed(webhook 리포트 확정).
* webhook 결과 수신(Phase 4) 전까지는 sent 상태로 머문다.
*/
enum DispatchStatus: string
{
case Pending = 'pending';
case Sent = 'sent';
case Success = 'success';
case Failed = 'failed';
/**
* 현재 locale 에 맞는 상태 라벨을 반환합니다.
*
* @return string 다국어 상태 라벨 (예: "대기", "발송중", "성공", "실패")
*/
public function label(): string
{
return match ($this) {
self::Pending => __('sirsoft-message_bizppurio::messages.status.pending'),
self::Sent => __('sirsoft-message_bizppurio::messages.status.sent'),
self::Success => __('sirsoft-message_bizppurio::messages.status.success'),
self::Failed => __('sirsoft-message_bizppurio::messages.status.failed'),
};
}
/**
* 리포트 수신으로 상태가 확정되었는지(성공/실패) 여부를 반환합니다.
*
* @return bool 성공 또는 실패이면 true, 대기/발송중이면 false
*/
public function isFinal(): bool
{
return $this === self::Success || $this === self::Failed;
}
/**
* 모든 상태 값 목록을 반환합니다.
*
* @return array<int, string> 상태 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 결과코드 분류 — 비즈뿌리오 결과코드를 처리 방침으로 분류한 값 집합.
*
* ResultCodeResolver(Phase 4)가 코드 → 이 분류로 매핑하고, 발송 재시도 정책
* (SendMessageJob, Phase 2)과 잔액부족 자체알림(Phase 4)의 판단 기준이 된다.
*
* - success: 발송/리포트 성공
* - retry: 일시 오류 → 재시도 대상
* - permanent_failure: 영구 실패 → 즉시 실패 처리
* - balance_low: 지갑 잔액 부족(9070 문자 / 7436 알림톡) → 실패 + 관리자 자체알림
*/
enum ResultCategory: string
{
case Success = 'success';
case Retry = 'retry';
case PermanentFailure = 'permanent_failure';
case BalanceLow = 'balance_low';
/**
* 현재 locale 에 맞는 분류 라벨을 반환합니다.
*
* @return string 다국어 분류 라벨
*/
public function label(): string
{
return match ($this) {
self::Success => __('sirsoft-message_bizppurio::messages.result_category.success'),
self::Retry => __('sirsoft-message_bizppurio::messages.result_category.retry'),
self::PermanentFailure => __('sirsoft-message_bizppurio::messages.result_category.permanent_failure'),
self::BalanceLow => __('sirsoft-message_bizppurio::messages.result_category.balance_low'),
};
}
/**
* 재시도 대상 분류 여부를 반환합니다.
*
* @return bool 일시 오류(retry)이면 true
*/
public function isRetryable(): bool
{
return $this === self::Retry;
}
/**
* 발송 실패로 확정되는 분류 여부를 반환합니다.
*
* @return bool 영구 실패 또는 잔액 부족이면 true
*/
public function isFailure(): bool
{
return $this === self::PermanentFailure || $this === self::BalanceLow;
}
/**
* 모든 분류 값 목록을 반환합니다.
*
* @return array<int, string> 분류 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Exceptions;
use RuntimeException;
use Throwable;
/**
* 비즈뿌리오 API 호출 실패 예외.
*
* 발송(api.bizppurio.com)·카카오 관리(kapi.ppurio.com) 두 시스템의 API 호출
* 단계에서 발생하는 실패(HTTP 오류, 응답 code 비정상, 응답 파싱 실패 등)를 단일
* 도메인 예외로 통합한다. 베이스 \Exception 직접 throw 대신 본 클래스를 사용해
* 외부 소비자가 비즈뿌리오 도메인 오류만 선택적으로 catch 할 수 있도록 한다.
*
* 비즈뿌리오 응답 결과코드(예: 3002 토큰무효, 3006 계정오류)를 함께 보존하여
* SendMessageJob 의 재시도/영구실패 판정(ResultCodeResolver, Phase 4)이 코드로
* 분기할 수 있게 한다. HTTP 전송 자체가 실패한 경우 결과코드는 null 이다.
*/
class BizppurioApiException extends RuntimeException
{
/**
* @param string $message 예외 메시지
* @param string|null $resultCode 비즈뿌리오 응답 결과코드(있으면). 전송 실패 시 null
* @param int|null $httpStatus HTTP 상태 코드(있으면)
* @param int $code 예외 코드(기본 0)
* @param Throwable|null $previous 원인 예외
*/
public function __construct(
string $message = '',
private readonly ?string $resultCode = null,
private readonly ?int $httpStatus = null,
int $code = 0,
?Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
/**
* 비즈뿌리오 응답 결과코드를 반환합니다.
*
* @return string|null 결과코드(예: "3002"), 전송 실패 시 null
*/
public function getResultCode(): ?string
{
return $this->resultCode;
}
/**
* HTTP 상태 코드를 반환합니다.
*
* @return int|null HTTP 상태 코드, 없으면 null
*/
public function getHttpStatus(): ?int
{
return $this->httpStatus;
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Exceptions;
use RuntimeException;
/**
* 발송 준비 미비로 인한 발송 건너뜀 예외.
*
* 알림톡 템플릿 미연결, SMS 템플릿 없음, 수신자 전화번호 없음 등 비즈뿌리오
* API 호출 자체를 시도하지 못하고 채널 드라이버 단계에서 건너뛴 경우 던진다.
* 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이를
* core.notification.channel_send_failed 훅으로 연결해, 발송 이력 화면에
* "성공"이 아닌 "실패"로 정확히 기록되게 한다.
*/
class NotificationSendSkippedException extends RuntimeException {}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* 비즈뿌리오 webhook(URL PUSH) IP 화이트리스트 미들웨어.
*
* 비즈뿌리오 공식 발송 IP 만 허용한다. webhook 은 토큰/IDV 미들웨어를 제외(라우트
* 레벨 withoutMiddleware)하고 인증을 이 IP 화이트리스트로 대체하므로, 이 게이트가
* 위·변조 리포트에 대한 1차 방어선이다.
*
* 환경별 우회를 두지 않는다(계획서 결정 A). testing/local 에서도 동일하게 IP 를
* 검증해, "차단 IP → 403" 이 테스트로 실증되고 프로덕션과 동일 경로를 검증한다.
*/
class BizppurioWebhookIpWhitelist
{
/** 비즈뿌리오 공식 URL PUSH 발송 IP (매뉴얼 부록 C-4) */
private const ALLOWED_IPS = [
'115.71.53.78',
'115.71.53.79',
'115.71.53.94',
'115.71.53.95',
];
/**
* 요청 IP 가 화이트리스트에 있는지 확인하고 통과 여부를 결정합니다.
*
* @param Request $request 들어온 HTTP 요청
* @param Closure $next 다음 미들웨어
* @return Response 다음 미들웨어 응답 또는 403
*/
public function handle(Request $request, Closure $next): Response
{
if (! in_array($request->ip(), self::ALLOWED_IPS, true)) {
abort(403, 'Forbidden');
}
return $next($request);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 비즈뿌리오 webhook(URL PUSH) 리포트 검증 Request.
*
* 필수 필드(부록 C-4): DEVICE(메시지유형) · CMSGID(메시지키) · MSGID(비즈뿌리오키) ·
* PHONE(수신번호) · MEDIA(실발송유형) · RESULT(결과코드) · REFKEY(우리 부여 키).
* 선택 필드: TO NAME · WAPINFO · TELRES/TELTIME(대체발송 결과) · KAORES/KAOTIME 등.
*
* 인증/권한은 라우트의 IP 화이트리스트 미들웨어가 담당하므로 authorize() 는 true 고정.
*/
class BizppurioWebhookRequest extends FormRequest
{
/**
* 권한 검사는 IP 화이트리스트 미들웨어가 담당 — 항상 통과.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* webhook 리포트 검증 규칙.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'DEVICE' => ['nullable', 'string', 'max:20'],
'CMSGID' => ['nullable', 'string', 'max:64'],
'MSGID' => ['nullable', 'string', 'max:64'],
'PHONE' => ['nullable', 'string', 'max:20'],
'MEDIA' => ['nullable', 'string', 'max:10'],
'RESULT' => ['required', 'string', 'max:10'],
'REFKEY' => ['required', 'string', 'max:32'],
'TELRES' => ['nullable', 'string', 'max:10'],
'KAORES' => ['nullable', 'string', 'max:10'],
];
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 코어 알림 발송 이력 결과 배치 조회 검증 (A-2 표시 주입).
*
* 코어 알림 발송 이력 화면이 현재 페이지의 코어 알림 로그 id 배열을 넘겨 비즈뿌리오 결과를 한 번에
* 조회한다. 한 페이지(최대 100건) 분량으로 배열 크기를 제한해 과도한 조회를 막는다.
*/
class DispatchResultLookupRequest extends FormRequest
{
/**
* 권한은 라우트 미들웨어(messaging.view)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// 빈 배열 허용: 코어 알림 발송 이력이 0건(또는 로드 전)이면 화면이 빈 배열을 보낸다.
// 이때 422 로 막으면 결과 컬럼 조회 자체가 에러가 되므로, present(키 존재)만 요구하고
// 빈 배열은 통과시켜 빈 결과 맵을 돌려준다.
'notification_log_ids' => ['present', 'array', 'max:100'],
'notification_log_ids.*' => ['integer', 'min:1'],
];
}
/**
* 검증된 코어 알림 로그 id 목록을 정수 배열로 반환합니다.
*
* @return array<int, int> 로그 id 목록
*/
public function logIds(): array
{
return array_map('intval', $this->validated('notification_log_ids', []));
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 알림톡 연동 즉시 저장 검증 (계획서 §6-2, Phase 6).
*
* 코어 편집 모달 전용 칸에서 연결 템플릿·SMS 대체를 바꾸면 즉시 이 요청으로 저장된다.
* template_code 는 nullable — 비어 있으면 연동 해제로 처리한다("연결 안 함" 선택).
*/
class StoreNotificationBindingRequest extends FormRequest
{
/**
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'notification_type' => ['required', 'string', 'max:100'],
'template_code' => ['nullable', 'string', 'max:50'],
'template_name' => ['nullable', 'string', 'max:255'],
'fallback_sms_enabled' => ['sometimes', 'boolean'],
];
}
}
@@ -0,0 +1,176 @@
<?php
// audit:allow job-generator-needs-scale-test 단건 발송 Job(대용량 데이터 생성/처리 아님) — 결과코드 재시도 판정 집합만 변경, 대량 회귀 무관
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioApiClient;
use Throwable;
/**
* 비즈뿌리오 메시지 발송 Job.
*
* MessagePayloadBuilder 가 조립한 발송 payload 를 큐에서 BizppurioApiClient 로
* 전송한다. 서버 QUEUE_CONNECTION 을 따르며, 아래 재시도 정책을 적용한다(계획서 D10):
*
* - 429(Rate Limit): 예외를 던져 큐가 재시도. sync 환경은 최대 2초 대기 후 1회 재시도.
* - 일시 오류(5003·5004·5005·9000·3011·3013): 예외를 던져 재시도.
* - 영구 실패(2000·3001·3004·3006·3007·3009·9070·7436 등): 예외 없이 종료(재시도 안 함).
*
* 결과코드의 정식 분류·이력 갱신은 Phase 4(ResultCodeResolver/webhook)가 담당한다.
* 이 Job 은 발송 수행과 재시도 판정까지의 골격을 제공한다.
*/
class SendMessageJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
/** 최대 재시도 횟수 (429·일시오류 대상). sync 환경 1회 재시도 포함 */
public int $tries = 2;
/**
* 재시도 대상 일시 오류 결과코드.
*
* 공통 일시오류(5003/5004/5005/9000/3011/3013) + 알림톡 일시오류(7306 카카오 시스템오류·
* 7307 처리지연·7421 타임아웃·7437 메시지 요청실패). 7305(성공 불확실)는 중복발송 위험으로 제외.
* ResultCodeResolver::RETRYABLE_CODES 와 동일 집합을 유지한다.
*/
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'];
/** HTTP 429 재시도 시 sync 환경 최대 대기(초) */
private const SYNC_RETRY_MAX_WAIT_SECONDS = 2;
/**
* @param array<string, mixed> $payload 발송 payload (MessagePayloadBuilder 조립)
* @param string $refkey 우리 부여 키 (이력 매칭용)
*/
public function __construct(
public readonly array $payload,
public readonly string $refkey,
) {
$this->afterCommit = true;
}
/**
* 발송을 수행하고 결과코드에 따라 재시도/실패를 판정합니다.
*
* 발송 응답으로 이력(bizppurio_dispatches)을 갱신한다(Phase 4):
* - 성공: status=sent(발송 접수 완료, 최종 성공은 webhook 리포트가 확정) + messagekey 저장.
* - 재시도: 예외를 던져 큐 재시도(이력은 pending 유지).
* - 영구 실패: status=failed + result_code 기록.
*
* @param BizppurioApiClient $client 발송 API 클라이언트
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리
*
* @throws BizppurioApiException 일시 오류·429 시(큐 재시도 트리거)
*/
public function handle(BizppurioApiClient $client, BizppurioDispatchRepositoryInterface $dispatches): void
{
$result = $client->sendMessage($this->payload);
$code = (string) ($result['code'] ?? '');
if ($client->isSuccess($result)) {
// 발송 접수 성공 → sent(최종 성공/실패는 webhook 리포트가 확정) + messagekey 저장.
$this->updateDispatch($dispatches, [
'status' => DispatchStatus::Sent->value,
'messagekey' => $result['messagekey'] ?? null,
]);
return;
}
if (in_array($code, self::RETRYABLE_CODES, true)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.send_retryable', ['code' => $code]),
resultCode: $code,
);
}
// 영구 실패: 재시도하지 않고 종료 + 실패 이력 기록.
$this->updateDispatch($dispatches, [
'status' => DispatchStatus::Failed->value,
'result_code' => $code,
'result_message' => $result['description'] ?? null,
]);
Log::warning('비즈뿌리오 발송 영구 실패', [
'refkey' => $this->refkey,
'code' => $code,
'description' => $result['description'] ?? null,
]);
}
/**
* refkey 로 발송 이력을 조회해 갱신합니다 (이력이 없으면 무시).
*
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리
* @param array<string, mixed> $data 갱신 데이터
*/
private function updateDispatch(BizppurioDispatchRepositoryInterface $dispatches, array $data): void
{
$dispatch = $dispatches->findByRefkey($this->refkey);
if ($dispatch !== null) {
$dispatches->update($dispatch, $data);
}
}
/**
* 재시도 사이 대기 시간(초)을 반환합니다.
*
* 429·일시오류 재시도 시 짧게 backoff 한다. sync 실행 시에도 이 값이 적용된다.
*
* @return int 대기 초
*/
public function backoff(): int
{
return self::SYNC_RETRY_MAX_WAIT_SECONDS;
}
/**
* 모든 재시도 소진 후 최종 실패 시 로그를 남기고 발송 이력을 실패로 마감합니다.
*
* 타임아웃·연결실패(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' => $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(),
]);
}
}
@@ -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');
}
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
/**
* 비회원 주문 알림에 수신 전화번호를 실어주는 extract_data 필터 리스너 (D1).
*
* 코어 GuestNotifiable 과 이커머스 guest_recipient 표준 키는 email/name/locale 만 담고
* 전화번호가 없다. 따라서 비회원 SMS/알림톡을 보내려면 각 도메인의 extract_data 훅을
* 구독해 발송 채널 드라이버가 읽을 수 있는 표준 키(SmsChannelDriver::RECIPIENT_PHONE_KEY)로
* 전화번호를 data 에 채워야 한다. 이 리스너가 그 역할을 이커머스 주문 알림에 대해 수행한다.
*
* 의존 방향: 플러그인 → 이커머스(훅 구독 + 공개 관계 필드 읽기). 이커머스는 우리 전용 키를
* 모르고, 우리는 이커머스가 이미 GuestOrderAuthService·OrderResource 에서 하듯 배송지의
* orderer_phone 을 직접 읽는다(이커머스 모듈 무수정). 이커머스 미설치 시 이 훅은 발화하지
* 않으며, 설령 호출돼도 instanceof Order 게이트에서 무해하게 통과한다.
* 이커머스 자체 extract_data 리스너(기본 priority 10) 뒤에 실행되도록 priority 20 으로 둔다.
*/
class GuestPhoneExtractListener implements HookListenerInterface
{
/** 전화번호를 보강할 이커머스 주문 알림 유형 */
private const ORDER_NOTIFICATION_TYPES = [
'order_confirmed',
'order_pending_deposit',
'order_shipped',
'order_delivered',
'order_completed',
'order_cancelled',
];
/**
* 구독할 훅 목록 반환.
*
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'sirsoft-ecommerce.notification.extract_data' => [
'method' => 'injectGuestPhone',
'priority' => 20,
'type' => 'filter',
],
];
}
/**
* 기본 핸들러 (미사용 — 필터 메서드로 처리).
*
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* 비회원 주문 알림의 data 에 수신 전화번호를 주입합니다.
*
* 이커머스 리스너가 만든 extract_data 결과(`{notifiable, notifiables, data, context}`)를
* 받아, 대상이 주문 알림이고 비회원 주문이면 data 에 전화번호를 덧붙여 반환한다.
* 그 외에는 원본을 그대로 통과시킨다(회원 주문은 드라이버가 회원 mobile 을 사용).
*
* @param array<string, mixed> $result 이커머스 extract_data 결과
* @param string $type 알림 정의 유형
* @param array<int, mixed> $args 훅 원본 인수 ([$order, ...])
* @return array<string, mixed> 전화번호가 보강된(또는 원본) 결과
*/
public function injectGuestPhone(array $result, string $type, array $args): array
{
if (! in_array($type, self::ORDER_NOTIFICATION_TYPES, true)) {
return $result;
}
$order = $args[0] ?? null;
if (! $order instanceof Order || ! $order->isGuestOrder()) {
return $result;
}
// 주문자 전화번호(배송지 orderer_phone)를 이커머스 자체 관행(GuestOrderAuthService·
// OrderResource 와 동일)대로 관계 필드에서 직접 읽는다. 이커머스 모듈 무수정.
$phone = $order->shippingAddress?->orderer_phone;
if ($phone === null || trim((string) $phone) === '') {
return $result;
}
$result['data'] = array_merge(
$result['data'] ?? [],
[SmsChannelDriver::RECIPIENT_PHONE_KEY => $phone],
);
return $result;
}
}
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use App\Models\NotificationLog;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
/**
* 코어 알림 로그 ↔ 비즈뿌리오 발송건 연결 리스너 (A-2 연결고리).
*
* 코어 알림 발송 이력 화면에서 비즈뿌리오 결과(성공/실패·사유·잔액부족·대체발송)를 그 행에
* 붙이려면, 코어 로그(notification_logs) 와 우리 dispatch(bizppurio_dispatches) 를 이어야 한다.
* 코어 로그 테이블 무수정 원칙(계획서 ⚑ 결정 1)에 따라, 연결 표식은 우리 dispatch 쪽에만 둔다
* (bizppurio_dispatches.notification_log_id).
*
* 코어가 로그 생성 직후 발화하는 확장점을 구독한다:
* - core.notification_log.after_log_sent — 발송 성공 로그 생성 직후(NotificationLog 전달)
* - core.notification_log.after_log_failed — 발송 실패 로그 생성 직후(NotificationLog 전달)
*
* 매칭 방식(A안, PO 확정): 복합키 근사 매칭이 아니라 발송 사이클 refkey 직접 표식이다.
* 같은 채널 발송 한 사이클에서 우리 드라이버 send() 가 dispatch(refkey) 를 먼저 만들고
* DispatchLinkContext 에 refkey 를 남긴 뒤, 곧바로 이 로그 훅이 발화한다. 여기서 그 refkey 를
* 꺼내(consume) 그 dispatch 에 방금 만들어진 코어 로그 id 를 기록한다.
*
* 비-비즈뿌리오 채널(mail/database 등)의 로그가 발화한 경우엔 이 사이클에서 remember 된 refkey 가
* 없으므로 consume() 이 null 을 반환하고 조용히 넘어간다(그 로그는 연결 대상 아님).
*/
class LinkNotificationLogListener implements HookListenerInterface
{
/**
* @param BizppurioDispatchRepositoryInterface $dispatches dispatch 연결 위임
* @param DispatchLinkContext $linkContext 발송 사이클 refkey 컨텍스트
*/
public function __construct(
private readonly BizppurioDispatchRepositoryInterface $dispatches,
private readonly DispatchLinkContext $linkContext,
) {}
/**
* 구독할 훅 목록 반환.
*
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'core.notification_log.after_log_sent' => ['method' => 'linkLog', 'priority' => 15],
'core.notification_log.after_log_failed' => ['method' => 'linkLog', 'priority' => 15],
];
}
/**
* 기본 핸들러 (미사용 — linkLog 로 처리).
*
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* 방금 생성된 코어 알림 로그를 이번 발송 사이클의 dispatch 에 연결합니다.
*
* 이 사이클에서 우리 드라이버가 남긴 refkey 가 있으면 그 dispatch 에 로그 id 를 기록한다.
* refkey 가 없으면(비-비즈뿌리오 로그) 연결하지 않는다.
*
* @param NotificationLog $log 코어가 방금 생성한 로그(id 포함)
*/
public function linkLog(NotificationLog $log): void
{
$refkey = $this->linkContext->consume();
if ($refkey === null) {
return;
}
try {
$this->dispatches->linkNotificationLog($refkey, (int) $log->id);
} catch (\Throwable $e) {
Log::warning('비즈뿌리오: 코어 알림 로그 연결 실패', [
'refkey' => $refkey,
'notification_log_id' => $log->id,
'error' => $e->getMessage(),
]);
}
}
}
@@ -0,0 +1,373 @@
<?php
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;
/**
* 비즈뿌리오 문자·알림톡 채널을 코어 알림 시스템에 등록하는 필터 리스너.
*
* 코어를 수정하지 않고 다음 4계열 필터 훅을 구독해 sms·alimtalk 채널을 노출·게이트한다.
*
* 1) core.notification.filter_available_channels
* - 알림 설정 화면의 채널 탭 SSoT. sms·alimtalk 메타(name_key/allow_guest 등)를 추가한다.
* 2) core.notification.channel_readiness
* - 채널별 환경설정 충족 여부({ready, reason})를 반환한다(D2).
* 3) {prefix}.notification.channels (core.auth / sirsoft-ecommerce / sirsoft-board)
* - 레거시 다채널 자동 결정 경로에서 정의별 채널 후보에 sms·alimtalk 을 더한다(D7 3영역).
*
* 실제 발송 위임({prefix}.notification.to_sms/to_alimtalk)은 채널 드라이버가
* ChannelManager 에 등록되어야 발화하므로, 드라이버 등록은 ServiceProvider::boot() 가
* 담당한다(이 리스너는 채널 "노출·판정"만 책임진다).
*
* 알림톡 탭은 코어 기본 목록을 그대로 사용한다(Phase 6 재설계, 계획서 ⚑⚑ 블록 A).
* 연결 템플릿·SMS 대체 등 알림톡 전용 설정은 코어 목록 행 하단에 overlay 로 얹은
* [연결/변경] 버튼 → 우리 연결 모달 → 우리 API(notification-bindings)로 직접 저장하므로,
* 이 리스너는 코어 목록을 숨기는 별도 플래그를 두지 않는다.
*/
class RegisterNotificationChannelsListener implements HookListenerInterface
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** lang 네임스페이스 접두사 (plugin.php 와 동일) */
private const LANG = 'sirsoft-message_bizppurio::messages';
/** 이 플러그인이 등록하는 채널 ID 목록 (Plugin::cleanupChannelContributions 에서도 참조) */
public const CHANNEL_IDS = ['sms', 'alimtalk'];
/** 3영역 채널 후보 확장을 구독할 hookPrefix (D7) */
private const CHANNEL_HOOK_PREFIXES = ['core.auth', 'sirsoft-ecommerce', 'sirsoft-board'];
/**
* @param PluginSettingsService $pluginSettings readiness 검사를 위한 환경설정 조회
*/
public function __construct(
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 구독할 훅 목록 반환.
*
* filter_available_channels / channel_readiness 는 고정 훅이고,
* {prefix}.notification.channels 는 3영역 prefix 별로 동적으로 펼쳐 등록한다.
*
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
$hooks = [
'core.notification.filter_available_channels' => [
'method' => 'addChannels',
'priority' => 20,
'type' => 'filter',
],
'core.notification.channel_readiness' => [
'method' => 'checkReadiness',
'priority' => 20,
'type' => 'filter',
],
'core.notification.channel_enabled' => [
'method' => 'gateChannelEnabled',
'priority' => 20,
'type' => 'filter',
],
];
foreach (self::CHANNEL_HOOK_PREFIXES as $prefix) {
$hooks["{$prefix}.notification.channels"] = [
'method' => 'addChannelCandidates',
'priority' => 20,
'type' => 'filter',
];
}
return $hooks;
}
/**
* 기본 핸들러 (미사용 — 필터 메서드로 처리).
*
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* 사용 가능한 채널 목록에 sms·alimtalk 메타를 추가합니다.
*
* 코어 NotificationChannelService::getAvailableChannels() 가 name_key/description_key/
* source_label_key 를 활성 locale 로 해석하므로(localized_payload), lang key 로 선언한다.
* 중복 방지: 이미 존재하는 id 는 다시 추가하지 않는다.
*
* allow_guest:true — 비회원 문자 발송 허용(D1). 실제 게스트 전화번호는 SmsChannelDriver 가
* data 에서 해석한다.
*
* @param array<int, array<string, mixed>> $channels 현재 채널 메타 목록
* @return array<int, array<string, mixed>> sms·alimtalk 이 추가된 목록
*/
public function addChannels(array $channels): array
{
$existingIds = array_column($channels, 'id');
// 검수(테스트) 모드 여부를 채널 메타에 실어 프론트(availableChannels)로 전달한다.
// 알림톡·SMS 탭 상단 상태 배너가 이 값으로 "테스트 모드 — 실제 발송 안 됨"을 노출한다.
// 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다.
$isTestMode = $this->isTestMode();
foreach ($this->channelMetas() as $meta) {
if (! in_array($meta['id'], $existingIds, true)) {
$meta['is_test_mode'] = $isTestMode;
$channels[] = $meta;
}
}
return $channels;
}
/**
* 채널 준비 상태를 검사합니다 (core.notification.channel_readiness).
*
* 코어/타 플러그인 채널의 판정({ready,reason})은 그대로 통과시키고, 우리 채널(sms/alimtalk)
* 일 때만 환경설정 충족 여부로 교체한다(D2).
*
* - sms: bizppurio_id + password + sender_number
* - alimtalk: sms 조건 + api_key + sender_key
*
* @param array{ready: bool, reason: string|null} $result 이전 필터까지의 판정
* @param string $channelId 검사 대상 채널
* @return array{ready: bool, reason: string|null}
*/
public function checkReadiness(array $result, string $channelId): array
{
return match ($channelId) {
'sms' => $this->checkSmsReadiness(),
'alimtalk' => $this->checkAlimtalkReadiness(),
default => $result,
};
}
/**
* 우리 채널(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 을 더합니다.
*
* {prefix}.notification.channels 는 채널 미지정(다채널) 발송 경로에서만 발화하며(코어 확인),
* 채널 지정 경로에서는 filter_available_channels 가 SSoT 다. 두 경로 모두에서 채널이
* 누락되지 않도록 후보에 우리 채널 id 를 더한다(중복 제거).
*
* @param array<int, string> $channels 정의별 채널 id 후보
* @param string $type 알림 정의 유형 (미사용)
* @param object|null $notifiable 수신자 (미사용)
* @return array<int, string> 우리 채널이 더해진 후보
*/
public function addChannelCandidates(array $channels, string $type = '', ?object $notifiable = null): array
{
foreach (self::CHANNEL_IDS as $id) {
if (! in_array($id, $channels, true)) {
$channels[] = $id;
}
}
return array_values($channels);
}
/**
* sms·alimtalk 채널 메타 정의를 반환합니다.
*
* @return array<int, array<string, mixed>>
*/
private function channelMetas(): array
{
return [
[
'id' => 'sms',
'name_key' => self::LANG.'.channels.sms.name',
'description_key' => self::LANG.'.channels.sms.description',
'icon' => 'fas fa-comment-sms',
'source' => self::PLUGIN_IDENTIFIER,
'source_label_key' => self::LANG.'.channels.source_label',
'allow_guest' => true,
],
[
'id' => 'alimtalk',
'name_key' => self::LANG.'.channels.alimtalk.name',
'description_key' => self::LANG.'.channels.alimtalk.description',
'icon' => 'fas fa-comment-dots',
'source' => self::PLUGIN_IDENTIFIER,
'source_label_key' => self::LANG.'.channels.source_label',
'allow_guest' => true,
],
];
}
/**
* SMS 채널 준비 상태를 검사합니다.
*
* @return array{ready: bool, reason: string|null}
*/
private function checkSmsReadiness(): array
{
if ($this->missing('bizppurio_id') || $this->missing('password')) {
return $this->notReady('sms_credentials_missing');
}
if ($this->missing('sender_number')) {
return $this->notReady('sms_sender_number_missing');
}
return ['ready' => true, 'reason' => null];
}
/**
* 알림톡 채널 준비 상태를 검사합니다.
*
* @return array{ready: bool, reason: string|null}
*/
private function checkAlimtalkReadiness(): array
{
if ($this->missing('bizppurio_id') || $this->missing('password')) {
return $this->notReady('sms_credentials_missing');
}
if ($this->missing('sender_number')) {
return $this->notReady('sms_sender_number_missing');
}
if ($this->missing('api_key')) {
return $this->notReady('alimtalk_api_key_missing');
}
if ($this->missing('sender_key')) {
return $this->notReady('alimtalk_sender_key_missing');
}
return ['ready' => true, 'reason' => null];
}
/**
* 환경설정 값이 비어 있는지 확인합니다.
*
* @param string $key 설정 키
* @return bool 비어 있으면 true
*/
private function missing(string $key): bool
{
$value = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, $key, '');
return trim((string) $value) === '';
}
/**
* 검수(테스트) 모드 여부를 반환합니다.
*
* is_test_mode=true 이면 실제 발송이 이뤄지지 않는다(검수 환경). 상태 배너 노출 기준.
*
* @return bool 검수 모드면 true
*/
private function isTestMode(): bool
{
return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true);
}
/**
* ready=false 판정을 lang reason key 와 함께 반환합니다.
*
* @param string $reasonKey messages.readiness.* 하위 키
* @return array{ready: bool, reason: string}
*/
private function notReady(string $reasonKey): array
{
return ['ready' => false, 'reason' => self::LANG.'.readiness.'.$reasonKey];
}
}
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 회원 대상 알림 정의에 sms·alimtalk 채널 template 을 증강하는 시딩 필터 리스너
* (계획서 §6-2 결정 D, Phase 6 재설계).
*
* 코어/모듈은 알림 정의를 DB 로 시딩하기 직전에 "정의 배열"을 필터 훅으로 한 번 통과시킨다
* (언어팩 다국어 병합용). 이 리스너가 그 훅에 편승해, 각 정의 배열에 sms·alimtalk 채널을
* channels 목록과 templates 에 끼워넣는다. 그러면 코어/모듈이 그것을 자기 선언처럼 저장하고
* (NotificationSyncHelper::syncTemplate), cleanupStaleTemplates 의 definedChannels 에 우리
* 채널이 포함되므로 재시딩·업데이트에도 삭제되지 않는다. 코어·모듈 파일은 수정하지 않는다.
*
* 구독 훅(3영역, 정의 배열을 넘기는 필터):
* - seed.notifications.translations (코어 — config/core.php)
* - seed.sirsoft-board.notifications.translations (게시판)
* - seed.sirsoft-ecommerce.notifications.translations (이커머스)
*
* 배열 형태 차이:
* - 코어: 연관 배열 `['welcome' => [...], ...]` (키가 type)
* - 모듈: 순차 배열 `[['type' => 'order_confirmed', ...], ...]`
* 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다.
*
* 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은
* 문자/알림톡 대상이 아니므로 건너뛴다. 기본 body 는 그 알림의 database 채널 body(짧은 평문)를
* 재활용하고, 없으면 mail body 의 HTML 을 제거해 만든다.
*/
class SeedChannelTemplatesListener implements HookListenerInterface
{
/** 증강할 채널 id 목록 */
private const CHANNELS = ['sms', 'alimtalk'];
/**
* 구독할 훅 목록 반환.
*
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
$hooks = [];
foreach ([
'seed.notifications.translations',
'seed.sirsoft-board.notifications.translations',
'seed.sirsoft-ecommerce.notifications.translations',
] as $hook) {
$hooks[$hook] = [
'method' => 'augment',
'priority' => 50,
'type' => 'filter',
];
}
return $hooks;
}
/**
* 기본 핸들러 (미사용 — 필터 메서드로 처리).
*
* @param mixed ...$args
*/
public function handle(...$args): void {}
/**
* 정의 배열의 각 회원 대상 알림에 sms·alimtalk 채널 template 을 증강합니다.
*
* 키(코어=type, 모듈=정수)와 순서를 보존한 채 각 정의 값만 변형한다. 정의 형태가 배열이
* 아니거나 회원 대상이 아니면 원본을 그대로 둔다.
*
* @param array<int|string, mixed> $definitions 시딩 직전 알림 정의 배열
* @return array<int|string, mixed> 채널이 증강된 정의 배열
*/
public function augment(array $definitions): array
{
foreach ($definitions as $key => $definition) {
if (! is_array($definition) || ! $this->isMemberFacing($definition)) {
continue;
}
$definitions[$key] = $this->augmentDefinition($definition);
}
return $definitions;
}
/**
* 단일 알림 정의에 sms·alimtalk 채널을 channels·templates 에 추가합니다.
*
* 이미 해당 채널이 선언돼 있으면(다른 경로로 이미 존재) 중복 추가하지 않는다.
*
* @param array<string, mixed> $definition 단일 알림 정의
* @return array<string, mixed> 증강된 정의
*/
private function augmentDefinition(array $definition): array
{
$channels = $definition['channels'] ?? ['mail'];
$templates = $definition['templates'] ?? [];
$existingChannels = array_column($templates, 'channel');
$baseBody = $this->resolveBaseBody($templates);
$baseSubject = $this->resolveBaseSubject($templates);
$recipients = $this->resolveRecipients($templates);
foreach (self::CHANNELS as $channel) {
if (in_array($channel, $existingChannels, true)) {
continue;
}
if (! in_array($channel, $channels, true)) {
$channels[] = $channel;
}
$templates[] = [
'channel' => $channel,
'recipients' => $recipients,
'subject' => $baseSubject,
'body' => $baseBody,
];
}
$definition['channels'] = $channels;
$definition['templates'] = $templates;
return $definition;
}
/**
* 알림이 회원(사용자) 대상인지 판정합니다 (결정 E).
*
* 정의의 어떤 template recipients 든 관리자 전용(type=role, value=admin)이 아닌 수신자가
* 하나라도 있으면 회원 대상으로 본다. recipients 가 전혀 없으면(수신자 미지정) 회원 발송
* 기본값으로 포함한다. channels 컬럼이 아니라 recipients 로 판정한다(결정 E).
*
* @param array<string, mixed> $definition 단일 알림 정의
* @return bool 회원 대상이면 true
*/
private function isMemberFacing(array $definition): bool
{
$templates = $definition['templates'] ?? [];
$hasAnyRecipient = false;
foreach ($templates as $template) {
foreach ($template['recipients'] ?? [] as $recipient) {
$hasAnyRecipient = true;
$type = $recipient['type'] ?? null;
$value = $recipient['value'] ?? null;
if (! ($type === 'role' && $value === 'admin')) {
return true;
}
}
}
return ! $hasAnyRecipient;
}
/**
* 증강할 채널의 기본 body(다국어 배열)를 결정합니다.
*
* 우선순위: database 채널 body(짧은 평문) → mail body 의 HTML 제거본 → 빈 배열.
* 문자/알림톡은 평문이므로 HTML 을 담지 않는다.
*
* @param array<int, array<string, mixed>> $templates 기존 template 목록
* @return array<string, string> locale → 평문 body
*/
private function resolveBaseBody(array $templates): array
{
$database = $this->findTemplate($templates, 'database');
if ($database !== null && is_array($database['body'] ?? null)) {
return $database['body'];
}
$mail = $this->findTemplate($templates, 'mail');
if ($mail !== null && is_array($mail['body'] ?? null)) {
return array_map(fn ($body) => $this->stripHtml((string) $body), $mail['body']);
}
return [];
}
/**
* 증강할 채널의 기본 subject(다국어 배열)를 결정합니다.
*
* database → mail subject 순으로 재활용한다. LMS 전환 시 제목으로 쓰인다.
*
* @param array<int, array<string, mixed>> $templates 기존 template 목록
* @return array<string, string> locale → subject
*/
private function resolveBaseSubject(array $templates): array
{
foreach (['database', 'mail'] as $channel) {
$template = $this->findTemplate($templates, $channel);
if ($template !== null && is_array($template['subject'] ?? null)) {
return $template['subject'];
}
}
return [];
}
/**
* 증강할 채널의 recipients 를 결정합니다.
*
* 기존 template(database → mail)의 recipients 를 그대로 재활용해 수신자 규칙을 일치시킨다.
* 없으면 발생 회원 본인(trigger_user) 기본값을 쓴다.
*
* @param array<int, array<string, mixed>> $templates 기존 template 목록
* @return array<int, array<string, mixed>> recipients 규칙
*/
private function resolveRecipients(array $templates): array
{
foreach (['database', 'mail'] as $channel) {
$template = $this->findTemplate($templates, $channel);
if ($template !== null && ! empty($template['recipients']) && is_array($template['recipients'])) {
return $template['recipients'];
}
}
return [['type' => 'trigger_user']];
}
/**
* template 목록에서 특정 채널의 template 을 찾습니다.
*
* @param array<int, array<string, mixed>> $templates template 목록
* @param string $channel 찾을 채널
* @return array<string, mixed>|null 매칭 template 또는 null
*/
private function findTemplate(array $templates, string $channel): ?array
{
foreach ($templates as $template) {
if (($template['channel'] ?? null) === $channel) {
return $template;
}
}
return null;
}
/**
* HTML 문자열을 문자/알림톡용 평문으로 변환합니다.
*
* 태그 제거 + 엔티티 디코드 + 연속 공백 정리. 변수 표기({name} 등)는 보존된다.
*
* @param string $html 원본 HTML
* @return string 평문
*/
private function stripHtml(string $html): string
{
$text = preg_replace('/<[^>]+>/', ' ', $html) ?? '';
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return trim((string) preg_replace('/\s+/', ' ', $text));
}
}
@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Illuminate\Support\Facades\Lang;
/**
* 비즈뿌리오 설정 저장 시 운영(live) 환경 조건부 검증을 주입하는 filter 훅 listener.
*
* 코어 UpdatePluginSettingsRequest 의 정적 스키마는 `required`/타입 규칙만 표현할 수 있어
* "검수 모드가 꺼진(운영) 상태일 때만 발송 자격증명 필수" 같은 조건부 검증을 담을 수 없다. 본
* listener 가 `core.plugin_settings.update_rules` filter 로 운영 진입 시 발송에 필요한
* 자격증명에 required 규칙을 동적 부여한다.
*
* 필수 대상은 "운영 환경에서 최소 문자 발송이 성립하는 조건"과 일치시킨다:
* - bizppurio_id, password : 발송 토큰 발급에 필수
* - sender_number : 발송 발신번호로 필수
* api_key(카카오 관리)·sender_key(알림톡)는 문자 발송의 필수 조건이 아니므로 제외한다.
*
* @since 1.0.0
*/
class ValidateBizppurioSettingsListener implements HookListenerInterface
{
/**
* 본 플러그인 식별자.
*/
private const IDENTIFIER = 'sirsoft-message_bizppurio';
/**
* 운영 환경에서 required 로 강제할 발송 자격증명 필드.
*
* @var array<int, string>
*/
private const LIVE_REQUIRED_FIELDS = ['bizppurio_id', 'password', 'sender_number'];
/**
* 구독 훅 메타데이터.
*
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'core.plugin_settings.update_rules' => [
'method' => 'addLiveModeRules',
'priority' => 10,
'type' => 'filter',
'sync' => true,
],
];
}
/**
* 인터페이스 표준 진입점 — getSubscribedHooks 가 method='addLiveModeRules' 를 명시하므로
* 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다.
*
* @param mixed ...$args 사용 안 함
*/
public function handle(...$args): void
{
// no-op — 실제 진입점은 addLiveModeRules() 메서드 (filter 훅)
}
/**
* 운영(live) 환경 진입 시 발송 자격증명에 required 규칙을 부여한다.
*
* @param array<string, mixed> $rules 코어가 스키마로 생성한 검증 규칙
* @param string $identifier 검증 대상 플러그인 식별자
* @return array<string, mixed> 조정된 검증 규칙
*/
public function addLiveModeRules(array $rules, string $identifier): array
{
if ($identifier !== self::IDENTIFIER) {
return $rules;
}
// 검수 모드(is_test_mode)가 명시적으로 false(운영) 일 때만 자격증명을 강제한다.
// 본 필터 훅(core.plugin_settings.update_rules)은 코어 UpdatePluginSettingsRequest 가
// "현재 요청의 is_test_mode 에 따른 조건부 검증 규칙"을 만들도록 발행하는 확장점이므로,
// 현재 입력 값 참조가 본질적으로 필요하다 (FormRequest 우회가 아님 — 코어 검증기 자체의 입력).
$isTestMode = filter_var(
// audit:allow listener-formrequest-bypass reason: 검증 규칙 필터 훅의 의도된 입력 모드 참조
request()->input('is_test_mode', true),
FILTER_VALIDATE_BOOLEAN,
FILTER_NULL_ON_FAILURE
);
if ($isTestMode !== false) {
return $rules;
}
// 운영 모드 검증이 확정된 이 시점(HTTP 요청 처리 중)에 현재 로케일로 항목 라벨을 등록한다.
// 요청 처리 시점에는 플러그인 lang 네임스페이스가 모두 준비돼 있어 폴백 없이 정확한
// 로케일 라벨이 잡힌다.
$this->registerLiveCredentialAttributeLabels();
foreach (self::LIVE_REQUIRED_FIELDS as $field) {
$fieldRules = (array) ($rules[$field] ?? []);
// 코어가 부여한 nullable 을 제거하고 required 로 강제.
$fieldRules = array_values(array_filter(
$fieldRules,
static fn ($rule) => $rule !== 'nullable'
));
if (! in_array('required', $fieldRules, true)) {
array_unshift($fieldRules, 'required');
}
$rules[$field] = $fieldRules;
}
return $rules;
}
/**
* 발송 자격증명 필드의 검증 에러 메시지 항목 라벨을 현재 요청 로케일로 등록한다.
*
* 코어 UpdatePluginSettingsRequest 의 검증 에러 메시지에서 항목 이름이 영문 키(`bizppurio id`)로
* 노출되지 않도록, 전역 validation.attributes 에 표시 이름을 런타임 병합한다. 코어 검증기를
* 수정하지 않고 Laravel 의 attribute 해석 메커니즘만 활용한다.
*/
private function registerLiveCredentialAttributeLabels(): void
{
$locale = app()->getLocale();
// addLines 가 validation 그룹을 attributes 만 든 빈 껍데기로 조기 캐시하면, 표준 Translator
// 의 isLoaded 가드로 인해 validation.php 전체(required 등)가 로드되지 않는 회귀가 발생한다.
// 따라서 addLines 전에 validation 그룹을 먼저 로드시켜 캐시를 정상으로 채운 뒤 attributes 만 보탠다.
Lang::get('validation.required', [], $locale);
Lang::addLines([
'validation.attributes.bizppurio_id' => __(self::IDENTIFIER.'::messages.settings.bizppurio_id_attribute', [], $locale),
'validation.attributes.password' => __(self::IDENTIFIER.'::messages.settings.password_attribute', [], $locale),
'validation.attributes.sender_number' => __(self::IDENTIFIER.'::messages.settings.sender_number_attribute', [], $locale),
], $locale);
}
}
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Models;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
/**
* 비즈뿌리오 발송 이력 모델.
*
* 문자·알림톡 1건 발송마다 1행. 발송 시 pending 으로 생성되고 webhook 리포트로 상태가
* 갱신된다. 발송 이력 화면(계획서 §6-5)의 데이터소스.
*
* @property int $id
* @property string $refkey
* @property string|null $messagekey
* @property string $channel
* @property string|null $media
* @property string $to_number
* @property string|null $to_name
* @property int|null $to_user_id
* @property string $content
* @property array|null $request_payload
* @property string|null $notification_type
* @property int|null $notification_log_id
* @property string $status
* @property string|null $result_code
* @property string|null $result_message
* @property string|null $fallback_status
* @property string $source
* @property bool|null $is_test_mode
* @property Carbon|null $sent_at
* @property Carbon|null $reported_at
* @property array|null $raw_payload
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read User|null $user
*/
class BizppurioDispatch extends Model
{
/**
* 테이블명
*
* @var string
*/
protected $table = 'bizppurio_dispatches';
/**
* 대량 할당 허용 필드
*
* @var array<int, string>
*/
protected $fillable = [
'refkey',
'messagekey',
'channel',
'media',
'to_number',
'to_name',
'to_user_id',
'content',
'request_payload',
'notification_type',
'notification_log_id',
'status',
'result_code',
'result_message',
'fallback_status',
'source',
'is_test_mode',
'sent_at',
'reported_at',
'raw_payload',
];
/**
* 속성 캐스팅 정의
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'channel' => DispatchChannel::class,
'status' => DispatchStatus::class,
'source' => DispatchSource::class,
'to_user_id' => 'integer',
'notification_log_id' => 'integer',
'is_test_mode' => 'boolean',
'sent_at' => 'datetime',
'reported_at' => 'datetime',
'request_payload' => 'array',
'raw_payload' => 'array',
];
}
/**
* 마스킹된 수신 전화번호를 반환합니다 (010-****-5678 형식).
*
* 가운데 4자리를 `*` 로 가린다. 뒤 4자리·앞 3자리는 노출한다. 전체보기는
* 권한(messaging.view)이 있는 상세 화면에서 원본 to_number 로 별도 제공한다.
*
* @return string 마스킹된 번호
*/
public function getMaskedNumberAttribute(): string
{
$digits = preg_replace('/[^0-9]/', '', $this->to_number) ?? '';
$len = strlen($digits);
if ($len < 7) {
// 너무 짧으면 뒤 절반만 노출
return str_repeat('*', (int) ceil($len / 2)).substr($digits, (int) ceil($len / 2));
}
$head = substr($digits, 0, $len - 8 > 0 ? 3 : $len - 4);
$tail = substr($digits, -4);
return $head.'-****-'.$tail;
}
/**
* 회원 수신자 관계 (비회원이면 null).
*
* @return BelongsTo
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'to_user_id');
}
/**
* refkey 로 조회하는 스코프 (webhook 매칭).
*
* @param Builder $query
* @param string $refkey 우리 부여 키
* @return Builder
*/
public function scopeByRefkey(Builder $query, string $refkey): Builder
{
return $query->where('refkey', $refkey);
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결(설정) 모델.
*
* "어느 알림에 어느 알림톡 템플릿을 쓸지 + 대체발송 여부"를 저장한다.
* 알림 설정 알림톡 탭 편집 모달(계획서 §6-2)이 우리 API 로 저장하고,
* 알림톡 채널 드라이버(Phase 6)가 이 연결을 조회해 발송 대상 템플릿을 결정한다.
*
* @property int $id
* @property string $notification_type
* @property string $channel
* @property string $template_code
* @property string $template_name
* @property bool $fallback_sms_enabled
* @property bool $is_active
* @property \Carbon\Carbon|null $created_at
* @property \Carbon\Carbon|null $updated_at
*/
class BizppurioNotificationBinding extends Model
{
/**
* 테이블명
*
* @var string
*/
protected $table = 'bizppurio_notification_bindings';
/**
* 대량 할당 허용 필드
*
* @var array<int, string>
*/
protected $fillable = [
'notification_type',
'channel',
'template_code',
'template_name',
'fallback_sms_enabled',
'is_active',
];
/**
* 속성 캐스팅 정의
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'channel' => DispatchChannel::class,
'fallback_sms_enabled' => 'boolean',
'is_active' => 'boolean',
];
}
/**
* 활성 연동만 조회하는 스코프.
*
* @param Builder $query
* @return Builder
*/
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
/**
* 알림 유형으로 조회하는 스코프 (발송 시 template_code 해석).
*
* @param Builder $query
* @param string $notificationType 코어 notification_definitions.type
* @return Builder
*/
public function scopeByNotificationType(Builder $query, string $notificationType): Builder
{
return $query->where('notification_type', $notificationType);
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Providers;
use App\Extension\BasePluginServiceProvider;
use Illuminate\Notifications\ChannelManager;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService;
/**
* 비즈뿌리오 메시징 플러그인 서비스 프로바이더.
*
* BasePluginServiceProvider 가 Repository / Storage / Cache 자동 바인딩과
* 다국어 로드를 처리한다. 아래 배열에 등록만 하면 contextual binding 이 적용된다.
*/
class MessageBizppurioServiceProvider extends BasePluginServiceProvider
{
/** 플러그인 식별자 (manifest 와 일치) */
protected string $pluginIdentifier = 'sirsoft-message_bizppurio';
/**
* Repository 인터페이스 ↔ 구현체 매핑.
*
* Phase 4 에서 발송 이력·이벤트 연동 Repository 를 등록한다.
*
* @var array<class-string, class-string>
*/
protected array $repositories = [
BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class,
BizppurioNotificationBindingRepositoryInterface::class => BizppurioNotificationBindingRepository::class,
];
/**
* CacheInterface 가 필요한 서비스 (contextual binding).
*
* - BizppurioTokenService(Phase 2): 발송 토큰 캐시
* - WebhookReportService(Phase 4): 잔액부족 알림 쿨다운(D3 중복 방지)
*
* @var array<int, class-string>
*/
protected array $cacheServices = [
BizppurioTokenService::class,
WebhookReportService::class,
KakaoTemplateContentResolver::class,
];
/**
* 서비스 컨테이너 바인딩을 등록합니다.
*
* DispatchLinkContext 는 한 발송 사이클(HTTP 요청/큐 잡) 안에서 refkey↔코어 로그 연결을
* 잇기 위해 상태를 공유해야 하므로 scoped(요청 단위 싱글턴)로 바인딩한다. 채널 드라이버와
* LinkNotificationLogListener 가 같은 인스턴스를 주입받아 refkey 를 주고받는다(A-2).
*/
public function register(): void
{
parent::register();
$this->app->scoped(DispatchLinkContext::class);
}
/**
* 플러그인 루트 lang 디렉토리를 로드합니다.
*
* 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()`
* (= 플러그인 식별자) 네임스페이스로 로드한다.
*/
protected function loadExtensionTranslations(): void
{
$langPath = dirname($this->getProviderPath(), 2).'/lang';
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->translationNamespace());
}
}
/**
* 부팅 — 코어 알림 ChannelManager 에 sms 채널 드라이버를 등록합니다.
*
* 코어 GenericNotification::via() 가 반환하는 'sms' 채널 문자열은 코어에 드라이버가
* 없어 그대로 두면 발송 시 "Driver [sms] not supported" 예외가 난다. 코어를 수정하지
* 않고 ChannelManager::extend() 로 드라이버를 런타임 등록해, 'sms' 채널 발송이
* SmsChannelDriver::send() 로 위임되게 한다. 플러그인 비활성/삭제 시 이 등록도
* 사라져 코어가 안전하게 원복된다.
*
* 알림톡('alimtalk')도 동일하게 AlimtalkChannelDriver 로 위임한다(Phase 6). 관리자가
* 알림톡 탭에서 연결한 승인 템플릿(binding)이 있을 때만 실제 발송되고, 미연결 알림은
* 드라이버가 조용히 skip 한다.
*/
public function boot(): void
{
parent::boot();
// ChannelManager 는 지연 싱글턴(첫 알림 발송 시 해석)이므로 resolving 콜백으로 등록한다.
// 다만 다른 코드가 이미 해석해 둔 경우 resolving 이 발화하지 않으므로, 이미 해석돼
// 있으면 즉시 등록하여 어느 순서에서도 누락되지 않게 한다.
$this->app->resolving(
ChannelManager::class,
fn (ChannelManager $manager) => $this->registerChannelDrivers($manager),
);
if ($this->app->resolved(ChannelManager::class)) {
$this->registerChannelDrivers($this->app->make(ChannelManager::class));
}
}
/**
* 코어 알림 ChannelManager 에 이 플러그인의 채널 드라이버를 등록합니다.
*
* - sms: SmsChannelDriver 로 실제 발송 위임(Phase 3).
* - alimtalk: AlimtalkChannelDriver 로 실제 발송 위임(Phase 6). 연결된 승인 템플릿
* (binding)이 있을 때만 발송하고 미연결 알림은 드라이버가 skip 한다.
*
* @param ChannelManager $manager 코어 알림 채널 매니저
*/
private function registerChannelDrivers(ChannelManager $manager): void
{
$manager->extend('sms', fn ($app) => $app->make(SmsChannelDriver::class));
$manager->extend('alimtalk', fn ($app) => $app->make(AlimtalkChannelDriver::class));
}
}

Some files were not shown because too many files have changed in this diff Show More