feat(message_bizppurio): 임시 삭제된 비즈뿌리오 플러그인 복구 및 현행 규정 정합화

32afe9530 에서 게시판 반응 기능과 함께 임시 제거됐던 비즈뿌리오 메시징
플러그인 본체(137 파일)와 일본어 번들 언어팩(9 파일)을 삭제 직전 상태
(32afe9530~1)로 복구한다. 템플릿 신청·승인 개편의 기반 작업.

복구 범위
- plugins/_bundled/sirsoft-message_bizppurio 전체 + 번들 ja 언어팩
- 공유 파일의 비즈뿌리오 참조 복원: build-language-pack-ja.cjs 팩 정의,
 api-doc-unfilled-baseline.json(32건), docs/backend/api/README.md 표 행,
 audit coverage 노트·vite-sourcemap-env-gate 룰 주석, via 테스트 주석,
 ·AGENTS.md 확장 API 표(자동 재생성), 라우팅 패리티 스냅샷(+1)

삭제 이후 강화된 규정 2건 정합화
- TokenCheckController: 예외 원문을 메시지 키 자리에 전달하던 422 응답을
 키(token_check.failed) + errors.bizppurio_message 페이로드로 분리
 (GenericCatchStatusCodeContractTest 계약). 관리자 토스트는 errors 페이로드로
 상세 사유를 계속 표시하도록 레이아웃 동기 수정, lang ko/en/ja 키 추가
- AlimtalkTemplateController::index: base Request 주입 금지 룰에 따라
 AlimtalkTemplateListRequest FormRequest 신설 (형태 검증만 — kapi 위임 유지)

미복원(의도)
- 게시판·이커머스 CHANGELOG 의 알림톡 연결 문구 2줄은 연결 방식이 로
 재설계되므로 되살리지 않고, 완료 시점에 새 동작 기준으로 차기 버전에 기재

검증: TokenCheck 7 + AlimtalkController 10 + GenericCatch 계약 3 (PHPUnit),
플러그인 레이아웃 Vitest 137건, BindingShape 라우팅 패리티 8건 green.
audit 는 복구 전부터 baseline 처리된 API 문서 미채움 32건만 잔존.
This commit is contained in:
HeuJung
2026-08-22 22:39:36 +09:00
parent acdff53792
commit 6a8a537f10
151 changed files with 22226 additions and 2 deletions
+2 -1
View File
@@ -155,7 +155,7 @@
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
@@ -168,6 +168,7 @@
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `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) | 7 / 13 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
+1
View File
@@ -259,6 +259,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 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
@@ -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,86 @@
<?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)',
'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
],
'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' => '通知トーク',
],
'token_check' => [
'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
'failed' => '認証の確認に失敗しました。詳細な理由をご確認ください。',
],
];
@@ -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,227 @@
{
"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": "検査モードがオフになっているため、実際の顧客にSMS・アラート톡が発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。",
"sections": {
"api": {
"title": "API 連動",
"description": "発送システムとカカオ管理システムに共通に使用する連動情報です。"
},
"sending": {
"title": "発送 設定",
"description": "文字・アラート톡発送に使用する発信情報です。"
}
},
"fields": {
"bizppurio_id": {
"label": "ビズプリオ ID",
"hint": "発送・カカオ管理に共通に使用する ビズプリオ ID です。"
},
"password": {
"label": "ビズプリオモジュールパスワード",
"hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
},
"api_key": {
"label": "API キー",
"hint": "API キーは ビズプリオ カスタマーセンターに 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": "キャッシュ 初期化に失敗しました。"
},
"connection_check": {
"label": "接続確認",
"hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
"button": "接続確認",
"checking": "確認中...",
"success": "認証が正常に確認されました。IDとパスワードが正しいです。",
"failed": "認証確認に失敗しました。",
"unsaved_changes": "変更内容を先に保存してください。"
}
},
"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,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": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理"
}
}
@@ -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 @@
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}({});
@@ -0,0 +1,20 @@
# API 레퍼런스 문서 목차
> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반).
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
<!-- @generated:start:api-readme-index -->
- **문서 수**: 7 · **엔드포인트 수**: 13
| 문서 | 도메인 | 엔드포인트 |
| --- | --- | --- |
| [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 |
| [token.md](token.md) | `token` | 1 |
| [webhook.md](webhook.md) | `webhook` | 1 |
<!-- @generated:end -->
@@ -0,0 +1,229 @@
# 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`
**요청 파라미터**
| 파라미터 | 위치 | 타입 | 필수 | 제약 | 설명 |
| --- | --- | --- | --- | --- | --- |
| status | query | string | 아니오 | max 30 | kapi `templateStatus` 필터 값(어휘는 kapi 정의를 따름) |
| keyword | query | string | 아니오 | max 50 | 템플릿명/코드 검색어 |
| page | query | integer | 아니오 | min 1 | 페이지 번호(기본 1) |
| count | query | integer | 아니오 | min 1 | 페이지당 건수(기본값은 서버 설정) |
**요청 예시**
```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,74 @@
# Token API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 Token(연결 확인) 엔드포인트 레퍼런스입니다
2. POST /admin/token/check — 저장된 계정/비밀번호로 즉시 재인증(캐시 우회)
3. 성공 시 200 + 새 토큰을 캐시에 반영, 실패 시 422 + errors 에 비즈뿌리오 원문 사유(bizppurio_message) + result_code
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
### POST /api/plugins/sirsoft-message_bizppurio/admin/token/check
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.token.check -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.token.check`
- **인증/권한**: `auth:sanctum` + `admin` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
_요청 파라미터 없음. 저장된 플러그인 설정(`bizppurio_id`, `password`)을 사용합니다._
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/token/check HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_성공 응답은 `data` 없이 메시지만 반환합니다._
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다."
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 422 | Unprocessable Entity | 자격증명 미설정(계정/비밀번호 공란) 또는 비즈뿌리오 인증 실패 — `errors.bizppurio_message` 에 비즈뿌리오 실패 사유 원문, `errors.result_code` 에 비즈뿌리오 응답 결과코드(있으면) 동반. 비즈뿌리오 서버 연결 자체가 실패(타임아웃·DNS 등)한 경우도 422(연결 실패 안내 메시지, `result_code` 없음) |
<!-- @generated:end -->
**설명**
관리자가 설정 화면에서 저장한 비즈뿌리오 아이디·비밀번호가 실제로 유효한지 그 자리에서 확인하는 "연결 확인" 버튼이 호출하는 엔드포인트입니다. `BizppurioTokenService::verifyCredentials()` 가 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출해 재검증하며, 성공 시 새로 발급된 토큰을 캐시(TTL 23시간)에 반영해 확인 직후의 발송이 이 토큰을 그대로 재사용하게 합니다(불필요한 재발급 방지).
실패 시 `BizppurioApiException` 을 422 로 변환합니다 — 응답 `message` 는 고정 안내(`token_check.failed`)이고, 비즈뿌리오가 준 실패 사유 원문(응답 `description` 이 있으면 `token_issue_failed_with_reason` 형태로 조립된 문장)은 관리자 전용 진단 정보로 `errors.bizppurio_message` 에 담습니다(예외 원문을 메시지 키 자리에 전달하지 않는 예외→응답 매핑 규정). 응답 `errors.result_code` 는 비즈뿌리오 결과코드(예: `3007`)이며, HTTP 전송 자체가 실패한 경우 `null` 입니다.
비즈뿌리오 서버 자체에 연결할 수 없는 경우(타임아웃·DNS 실패 등)는 `BizppurioApiException` 이 아닌 `ConnectionException` 으로 던져지므로 별도 catch 하여 `error.connection_failed` 메시지와 함께 422 로 응답합니다(`result_code` 없이 500 대신 매끄러운 실패 안내).
프론트 화면(`admin/plugin_settings.json` "연결 확인" 필드)은 저장하지 않은 변경사항(`_local.hasChanges`)이 있으면 이 API 를 호출하지 않고 "변경사항을 먼저 저장해주세요" toast 만 표시합니다 — 저장 전 값으로 확인하면 실제 저장된 자격증명과 다른 결과가 나올 수 있기 때문입니다.
조회가 아닌 재인증(쓰기 성격의 외부 API 호출)이므로 `messaging.view` 가 아닌 `messaging.manage` 권한을 요구합니다.
@@ -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,113 @@
<?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.',
'token_issue_failed_with_reason' => 'Failed to issue the Bizppurio authentication token. (:reason)',
'send_failed' => 'Failed to send the message.',
'send_retryable' => 'Message delivery temporarily failed. (code: :code)',
'invalid_response' => 'Unable to parse the Bizppurio response.',
'connection_failed' => 'Unable to connect to the Bizppurio server. Please try again later.',
'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.',
],
// Connection check (TokenCheckController response)
'token_check' => [
'success' => 'Authentication verified successfully. The ID and password are correct.',
'failed' => 'Authentication check failed. See the detailed reason.',
],
];
@@ -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,113 @@
<?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' => '비즈뿌리오 인증 토큰 발급에 실패했습니다.',
'token_issue_failed_with_reason' => '비즈뿌리오 인증 토큰 발급에 실패했습니다. (:reason)',
'send_failed' => '메시지 발송 요청에 실패했습니다.',
'send_retryable' => '메시지 발송이 일시적으로 실패했습니다. (코드: :code)',
'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.',
'connection_failed' => '비즈뿌리오 서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
'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' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.',
],
// 연결 확인 (TokenCheckController 응답)
'token_check' => [
'success' => '인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.',
'failed' => '인증 확인에 실패했습니다. 상세 사유를 확인해 주세요.',
],
];
@@ -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.6",
"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,481 @@
<?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\InvalidateTokenOnSettingsSaveListener;
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: 환경설정 검증
* - InvalidateTokenOnSettingsSaveListener: 설정 저장 시 인증 토큰 캐시 무효화
*
* @return array<class-string>
*/
public function getHookListeners(): array
{
return [
RegisterNotificationChannelsListener::class,
SeedChannelTemplatesListener::class,
GuestPhoneExtractListener::class,
BalanceLowNotificationDataListener::class,
LinkNotificationLogListener::class,
ValidateBizppurioSettingsListener::class,
InvalidateTokenOnSettingsSaveListener::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(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (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 === (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 === (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": "{{(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(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (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 === (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 === (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": "{{(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(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (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 === (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 === (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": "{{(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,436 @@
/**
* 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§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, afterEach, vi } 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';
import {
createLayoutTest,
createMockComponentRegistryWithBasics,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
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 — 연결 확인 (§529)', () => {
it('field_connection_check 필드가 field_password 와 별개 노드로 존재한다', () => {
const field = findById(root, 'field_connection_check');
expect(field).toBeTruthy();
expect(field).not.toBe(findById(root, 'field_password'));
});
it('연결 확인 버튼이 캐시 초기화 버튼과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => {
const field = findById(root, 'field_connection_check');
const raw = JSON.stringify(field);
expect(raw).toContain('grid-cols-1');
expect(raw).toContain('lg:grid-cols-12');
expect(raw).toContain('lg:col-span-4');
expect(raw).toContain('lg:col-span-8');
expect(raw).toContain('lg:justify-end');
});
it('버튼은 대기 상태 plug, 로딩 상태 spinner(animate-spin) 아이콘을 조건부로 갖는다', () => {
const btn = findById(root, 'connection_check_button');
const raw = JSON.stringify(btn);
expect(raw).toContain('"plug"');
expect(raw).toContain('"spinner"');
expect(raw).toContain('animate-spin');
expect(raw).toContain('_local.tokenChecking');
});
it('버튼 클릭은 hasChanges=false 일 때만 apiCall 로 /admin/token/check 를 POST 한다', () => {
const btn = findById(root, 'connection_check_button');
const raw = JSON.stringify(btn);
expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/token/check');
expect(raw).toContain('"method":"POST"');
// apiCall 액션 자체가 !hasChanges 가드를 갖는다
const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? [];
const sequence = actions.find((a) => a.handler === 'sequence');
const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[];
const apiCallAction = inner.find((a) => a.handler === 'apiCall');
expect(apiCallAction?.if).toContain('!_local.hasChanges');
});
it('hasChanges=true 일 때는 apiCall 없이 unsaved_changes toast 만 실행한다', () => {
const btn = findById(root, 'connection_check_button');
const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? [];
const sequence = actions.find((a) => a.handler === 'sequence');
const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[];
const guardToast = inner.find((a) => a.handler === 'toast' && a.if === '{{_local.hasChanges}}');
expect(guardToast).toBeTruthy();
expect(JSON.stringify(guardToast)).toContain('connection_check.unsaved_changes');
});
it('성공/실패 결과는 화면 상시 표시 없이 toast 로만 안내한다', () => {
const btn = findById(root, 'connection_check_button');
const raw = JSON.stringify(btn);
expect(raw).toContain('connection_check.success');
expect(raw).toContain('connection_check.failed');
});
});
/**
* hasChanges 초기값(런타임) 검증 — §529 비판적 재검토에서 발견한 공백.
*
* 위 describe 블록은 레이아웃 JSON의 if 조건 문자열만 정적으로 확인한다. 하지만
* "페이지를 막 열고 아무것도 바꾸지 않은 상태(_local.hasChanges 가 아직 세팅 전)"
* 에서 실제로 어떻게 평가되는지는 런타임 값 — Boolean(undefined) 규칙에 따라
* `{{!_local.hasChanges}}` 는 true(API 호출 진행), `{{_local.hasChanges}}` 는
* false(경고 미노출) 가 되어야 정상이다. 실제 엔진(createLayoutTest)으로 렌더해
* 이 가정을 증명한다(추정이 아니라 확인).
*/
describe('plugin_settings.json — 연결 확인 버튼의 hasChanges 초기 상태 (§529 런타임 검증)', () => {
const connectionCheckButton = findById(root, 'connection_check_button') as AnyNode & {
actions: AnyNode[];
};
const clickAction = connectionCheckButton.actions.find((a) => a.handler === 'sequence') as AnyNode;
function buildProbe() {
return {
version: '1.0.0',
layout_name: 'test/connection-check-initial-state',
components: [connectionCheckButton],
};
}
afterEach(() => {
vi.clearAllMocks();
});
/**
* toast 핸들러는 ActionDispatcher 내장 처리(handleToast)라 커스텀 registerHandler
* 로 가로챌 수 없다 — 실제로는 globalStateUpdater 를 통해 `_global.toasts` 배열에
* 쌓인다(createLayoutTest 의 getToasts() 는 이 경로를 타지 않는 죽은 유틸리티임을
* 최소 재현으로 확인). 따라서 getState()._global.toasts 를 직접 읽는다.
*/
function lastToastMessages(utils: ReturnType<typeof createLayoutTest>): string[] {
const toasts = (utils.getState()._global?.toasts ?? []) as Array<{ message: string }>;
return toasts.map((t) => t.message);
}
it('로드 직후(hasChanges 미설정) 클릭하면 hasChanges 가드를 통과해 실제 API 응답까지 도달한다', async () => {
const registry = createMockComponentRegistryWithBasics();
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
utils.mockApi('token_check_probe', { response: { success: true } });
await utils.render();
// 초기 상태: hasChanges 는 아직 세팅되지 않음(undefined) — 저장 폼을 만지지 않은 상태.
// Boolean(undefined) === false 이므로 미저장 가드({{_local.hasChanges}})는 통과해야 한다.
expect(utils.getState()._local?.hasChanges).toBeFalsy();
await utils.triggerAction(clickAction);
// hasChanges 가드를 통과했다면 apiCall 이 실행되어 onSuccess/onError 중
// 하나가 반드시 toast 를 남긴다 — unsaved_changes 경고는 뜨지 않고,
// success 또는 failed(원격 호출 실패 응답 처리) 중 하나만 떠야 한다.
const messages = lastToastMessages(utils);
expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(false);
expect(messages.length).toBeGreaterThan(0);
utils.cleanup();
});
it('hasChanges=true 로 세팅된 뒤 클릭하면 API 호출 없이 미저장 경고 toast 만 뜬다', async () => {
const registry = createMockComponentRegistryWithBasics();
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
await utils.render();
utils.setState('hasChanges', true, 'local');
await utils.triggerAction(clickAction);
const messages = lastToastMessages(utils);
expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(true);
expect(messages.some((m) => m.includes('connection_check.success'))).toBe(false);
utils.cleanup();
});
});
describe('plugin_settings.json — 비밀번호 필드 라벨 (§529)', () => {
it('비밀번호 필드 라벨이 "비즈뿌리오 모듈 비밀번호"로 G7 로그인 비밀번호와 구분된다', () => {
expect((ko as Record<string, any>).settings.fields.password.label).toBe('비즈뿌리오 모듈 비밀번호');
expect((en as Record<string, any>).settings.fields.password.label).toBe('Bizppurio Module Password');
});
});
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,227 @@
{
"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": "Bizppurio Module Password",
"hint": "Please enter the Bizppurio module password. (Bizppurio console > Module Integration Settings > Change Module Password)"
},
"connection_check": {
"label": "Connection Check",
"hint": "Click to instantly verify the saved ID and password. (Only reflects saved values)",
"button": "Check Connection",
"checking": "Checking...",
"success": "Authentication verified successfully. The ID and password are correct.",
"failed": "Failed to verify authentication.",
"unsaved_changes": "Please save your changes first."
},
"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,227 @@
{
"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": "비즈뿌리오 모듈 비밀번호를 입력해주세요. (비즈뿌리오 콘솔 > 모듈연동 환경설정 > 모듈 비밀번호 변경)"
},
"connection_check": {
"label": "연결 확인",
"hint": "저장된 아이디·비밀번호가 유효한지 눌러서 바로 확인하세요. (저장 후에만 반영됩니다)",
"button": "연결 확인",
"checking": "확인 중...",
"success": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.",
"failed": "인증 확인에 실패했습니다.",
"unsaved_changes": "변경사항을 먼저 저장해주세요."
},
"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,118 @@
<?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\AlimtalkTemplateListRequest;
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(최대 50자)·page·count
*
* @param AlimtalkTemplateListRequest $request 검증된 목록 필터
* @return JsonResponse data 에 templates·pagination
*/
public function index(AlimtalkTemplateListRequest $request): JsonResponse
{
return $this->guard(function () use ($request) {
$result = $this->service->list($request->filters());
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,66 @@
<?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\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
/**
* 비즈뿌리오 인증 토큰 컨트롤러 — 설정 화면 "연결 확인" 버튼이 호출.
*
* 관리자가 설정 화면에서 저장한 계정/비밀번호가 실제로 유효한지 그 자리에서
* 확인할 수 있게 한다. 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출해
* 검증하며, 성공 시 발급된 토큰을 캐시에 반영한다(BizppurioTokenService::
* verifyCredentials 참고).
*/
class TokenCheckController extends AdminBaseController
{
/**
* @param BizppurioTokenService $tokenService 비즈뿌리오 인증 토큰 서비스
*/
public function __construct(
private readonly BizppurioTokenService $tokenService,
) {
parent::__construct();
}
/**
* 저장된 자격증명으로 토큰 발급을 즉시 재검증합니다.
*
* 비즈뿌리오 서버 자체에 연결할 수 없는 경우(타임아웃·DNS 실패 등)는
* BizppurioApiException 이 아닌 ConnectionException 으로 던져지므로 별도
* catch 하여 500 대신 422 로 응답한다.
*
* @return JsonResponse 성공 시 200(발급 확인), 실패 시 422(비즈뿌리오 실패 사유 또는 연결 실패 안내)
*/
public function check(): JsonResponse
{
try {
$this->tokenService->verifyCredentials();
return ResponseHelper::success('sirsoft-message_bizppurio::messages.token_check.success');
} catch (BizppurioApiException $e) {
// 비즈뿌리오가 준 실패 사유 원문은 메시지 키가 아니라 관리자 전용 errors
// 페이로드로 전달한다 (키 자리에 원문 전달 금지 — docs/backend/exceptions.md).
return ResponseHelper::error(
'sirsoft-message_bizppurio::messages.token_check.failed',
422,
[
'bizppurio_message' => $e->getMessage(),
'result_code' => $e->getResultCode(),
],
);
} catch (ConnectionException) {
return ResponseHelper::error(
'sirsoft-message_bizppurio::messages.error.connection_failed',
422,
);
}
}
}
@@ -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,58 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 알림톡 템플릿 목록 실시간 조회 검증 (Phase 5).
*
* 목록은 카카오 관리 API(kapi) 위임 조회이므로 값 해석은 kapi 가 담당한다. 여기서는
* 형태(문자열/정수)와 상한만 확인해 비정상 형태(배열 주입 등)의 전달을 차단한다.
* status 값 어휘는 kapi 의 templateStatus 정의를 따르므로 서버에서 닫힌 집합으로
* 좁히지 않는다(kapi 스펙 변경 시 화면만 갱신하면 되도록).
*/
class AlimtalkTemplateListRequest extends FormRequest
{
/**
* 권한은 라우트 미들웨어(messaging.view)에서 처리한다.
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['nullable', 'string', 'max:30'],
'keyword' => ['nullable', 'string', 'max:50'],
'page' => ['nullable', 'integer', 'min:1'],
'count' => ['nullable', 'integer', 'min:1'],
];
}
/**
* 서비스 list() 에 전달할 필터 배열을 반환합니다.
*
* @return array<string, mixed> status·keyword·page·count (미전달 키는 null)
*/
public function filters(): array
{
return [
'status' => $this->validated('status'),
'keyword' => $this->validated('keyword'),
'page' => $this->validated('page'),
'count' => $this->validated('count'),
];
}
}
@@ -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,83 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
/**
* 비즈뿌리오 설정 저장 시 인증 토큰 캐시를 무효화하는 listener.
*
* BizppurioTokenService 는 발급받은 토큰을 23시간(TTL) 캐시한다(만료 24h 대비
* 여유). 캐시가 계정/비밀번호 변경과 무관하게 살아있으면, 관리자가 자격증명을
* 바꿔도 최대 23시간 동안 옛 자격증명으로 발급된 토큰이 계속 재사용된다 —
* 발송 자체는 그동안 정상 동작하므로 변경 반영 여부를 즉시 확인할 방법이
* 없고, 토큰이 만료되어서야 뒤늦게 실패가 드러난다.
*
* 저장 시점에 무조건 캐시를 비워 다음 토큰 획득(발송 또는 "연결 확인" 버튼)이
* 항상 최신 자격증명으로 재인증하게 한다. 대상 필드(bizppurio_id/password)
* 변경 여부와 무관하게 매 저장마다 초기화한다 — 부분 필드만 골라 비교하는
* 것보다 단순하고, 불필요한 재발급 1회의 비용은 무시할 수 있는 수준이다.
*
* @since 1.0.0
*/
class InvalidateTokenOnSettingsSaveListener implements HookListenerInterface
{
/**
* 본 플러그인 식별자.
*/
private const IDENTIFIER = 'sirsoft-message_bizppurio';
/**
* @param BizppurioTokenService $tokenService 비즈뿌리오 인증 토큰 서비스
*/
public function __construct(
private readonly BizppurioTokenService $tokenService,
) {}
/**
* 구독 훅 메타데이터.
*
* @return array<string, array<string, mixed>>
*/
public static function getSubscribedHooks(): array
{
return [
'core.plugin_settings.after_save' => [
'method' => 'invalidateToken',
'priority' => 10,
'type' => 'action',
'sync' => true,
],
];
}
/**
* 인터페이스 표준 진입점 — getSubscribedHooks 가 method='invalidateToken' 를 명시하므로
* 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다.
*
* @param mixed ...$args 사용 안 함
*/
public function handle(...$args): void
{
// no-op — 실제 진입점은 invalidateToken() 메서드 (action 훅)
}
/**
* 본 플러그인 설정 저장 직후 토큰 캐시를 무효화한다.
*
* @param string $identifier 저장된 플러그인 식별자
* @param array<string, mixed> $settings 저장된 설정(사용 안 함)
* @param bool $result 저장 성공 여부
*/
public function invalidateToken(string $identifier, array $settings, bool $result): void
{
if ($identifier !== self::IDENTIFIER || ! $result) {
return;
}
$this->tokenService->forget();
}
}
@@ -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));
}
}
@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories;
use App\Repositories\Concerns\PaginatesWithDeferredJoin;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
/**
* 비즈뿌리오 발송 이력 Repository 구현체.
*/
class BizppurioDispatchRepository implements BizppurioDispatchRepositoryInterface
{
use PaginatesWithDeferredJoin;
/**
* 발송 이력 1건을 생성합니다.
*
* @param array<string, mixed> $data 이력 데이터
* @return BizppurioDispatch 생성된 이력
*/
public function create(array $data): BizppurioDispatch
{
return BizppurioDispatch::create($data);
}
/**
* refkey 로 발송 이력을 조회합니다.
*
* @param string $refkey 우리 부여 키
* @return BizppurioDispatch|null 매칭된 이력 또는 null
*/
public function findByRefkey(string $refkey): ?BizppurioDispatch
{
return BizppurioDispatch::query()->byRefkey($refkey)->first();
}
/**
* 발송 이력의 속성을 갱신합니다.
*
* @param BizppurioDispatch $dispatch 대상 이력
* @param array<string, mixed> $data 갱신 데이터
* @return BizppurioDispatch 갱신된 이력
*/
public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch
{
$dispatch->fill($data)->save();
return $dispatch;
}
/**
* 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다.
*
* @param array<string, mixed> $filters channel / status / date_from / date_to / keyword
* @param int $perPage 페이지당 건수
* @return LengthAwarePaginator<BizppurioDispatch>
*/
public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator
{
$query = BizppurioDispatch::query();
if (! empty($filters['channel'])) {
$query->where('channel', $filters['channel']);
}
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (! empty($filters['date_from'])) {
$query->where('sent_at', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->where('sent_at', '<=', $filters['date_to']);
}
if (! empty($filters['keyword'])) {
$keyword = $filters['keyword'];
$query->where(function ($q) use ($keyword) {
$q->where('to_number', 'like', "%{$keyword}%")
->orWhere('to_name', 'like', "%{$keyword}%")
->orWhere('refkey', 'like', "%{$keyword}%");
});
}
// 발송 이력은 발송할 때마다 쌓여 뒤쪽 페이지가 깊어진다. 지연 조인으로 OFFSET 구간에서는
// 키 컬럼만 훑고, 본문·요청/webhook 페이로드 같은 넓은 컬럼은 이번 페이지 행에서만 읽는다.
// 정렬 스펙 끝에 키 컬럼이 자동으로 덧붙어 동률(같은 시각 발송) 구간의 전순서도 보장된다.
//
// columns 를 좁히지 않은 이유: 이 목록을 소비하는 화면이 아직 없어 표시 컬럼 계약이
// 확정되지 않았다. OFFSET 구간의 넓은 컬럼 읽기는 지연 조인으로 이미 사라졌으므로,
// 컬럼 프루닝은 화면이 생겨 실제 사용 컬럼이 정해질 때 얹는다.
return $this->paginateWithDeferredJoin(
query: $query,
columns: ['*'],
sort: [['column' => 'created_at', 'direction' => 'desc']],
perPage: $perPage,
);
}
/**
* refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리).
*
* @param string $refkey 발송 사이클에서 부여한 refkey
* @param int $notificationLogId 코어 notification_logs.id
* @return bool 연결 성공 여부(대상 없으면 false)
*/
public function linkNotificationLog(string $refkey, int $notificationLogId): bool
{
$dispatch = BizppurioDispatch::query()->byRefkey($refkey)->first();
if ($dispatch === null) {
return false;
}
$dispatch->notification_log_id = $notificationLogId;
$dispatch->save();
return true;
}
/**
* 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시).
*
* @param array<int, int> $notificationLogIds 코어 로그 id 목록
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
*/
public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection
{
if ($notificationLogIds === []) {
return collect();
}
return BizppurioDispatch::query()
->whereIn('notification_log_id', $notificationLogIds)
->get()
->keyBy('notification_log_id');
}
/**
* 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
*
* @param int $limit 최근 dispatch 조회 상한
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
*/
public function recentLinkedKeyed(int $limit = 1000): Collection
{
return BizppurioDispatch::query()
->whereNotNull('notification_log_id')
->orderByDesc('id')
->limit($limit)
->get()
->keyBy('notification_log_id');
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결 Repository 구현체.
*/
class BizppurioNotificationBindingRepository implements BizppurioNotificationBindingRepositoryInterface
{
/**
* 알림 유형+채널로 활성 연동을 조회합니다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @return BizppurioNotificationBinding|null 매칭 연동 또는 null
*/
public function findActive(string $notificationType, string $channel = 'alimtalk'): ?BizppurioNotificationBinding
{
return BizppurioNotificationBinding::query()
->active()
->byNotificationType($notificationType)
->where('channel', $channel)
->first();
}
/**
* 채널의 모든 연동을 조회합니다.
*
* @param string $channel 채널
* @return Collection<int, BizppurioNotificationBinding>
*/
public function allByChannel(string $channel = 'alimtalk'): Collection
{
return BizppurioNotificationBinding::query()
->where('channel', $channel)
->get();
}
/**
* 알림 유형+채널 연동을 생성하거나 갱신합니다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @param array<string, mixed> $data 갱신 데이터
* @return BizppurioNotificationBinding 저장된 연동
*/
public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding
{
$binding = BizppurioNotificationBinding::firstOrNew([
'notification_type' => $notificationType,
'channel' => $channel,
]);
$binding->fill($data)->save();
return $binding;
}
/**
* 알림 유형+채널 연동을 삭제합니다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @return void
*/
public function delete(string $notificationType, string $channel = 'alimtalk'): void
{
BizppurioNotificationBinding::query()
->byNotificationType($notificationType)
->where('channel', $channel)
->delete();
}
}
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
/**
* 비즈뿌리오 발송 이력 Repository 계약.
*
* 발송 시 pending 이력을 생성하고, webhook 리포트로 refkey 매칭 후 상태를 갱신한다.
* 발송 이력 화면(계획서 §6-5)의 목록 조회를 담당한다.
*/
interface BizppurioDispatchRepositoryInterface
{
/**
* 발송 이력 1건을 생성합니다 (발송 시점, 통상 pending).
*
* @param array<string, mixed> $data 이력 데이터
* @return BizppurioDispatch 생성된 이력
*/
public function create(array $data): BizppurioDispatch;
/**
* refkey 로 발송 이력을 조회합니다 (webhook 매칭).
*
* @param string $refkey 우리 부여 키
* @return BizppurioDispatch|null 매칭된 이력 또는 null(위조)
*/
public function findByRefkey(string $refkey): ?BizppurioDispatch;
/**
* 발송 이력의 속성을 갱신합니다.
*
* @param BizppurioDispatch $dispatch 대상 이력
* @param array<string, mixed> $data 갱신 데이터
* @return BizppurioDispatch 갱신된 이력
*/
public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch;
/**
* 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다 (이력 화면).
*
* @param array<string, mixed> $filters channel / status / date_from / date_to / keyword
* @param int $perPage 페이지당 건수
* @return LengthAwarePaginator<BizppurioDispatch>
*/
public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator;
/**
* refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리).
*
* LinkNotificationLogListener 가 발송 사이클 직후 코어 로그 id 를 이 dispatch 에 기록할 때
* 호출한다. refkey 가 없거나(비-비즈뿌리오 로그) 이미 연결됐으면 아무 것도 하지 않는다.
*
* @param string $refkey 발송 사이클에서 부여한 refkey
* @param int $notificationLogId 코어 notification_logs.id
* @return bool 연결 성공 여부(대상 없으면 false)
*/
public function linkNotificationLog(string $refkey, int $notificationLogId): bool;
/**
* 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시).
*
* 코어 알림 발송 이력 화면이 현재 페이지의 log id 배열로 비즈뿌리오 결과를 한 번에 조회한다
* (N+1 회피). 매칭되지 않는 log id(메일·DB 등 비-비즈뿌리오)는 맵에서 빠진다.
*
* @param array<int, int> $notificationLogIds 코어 로그 id 목록
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
*/
public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection;
/**
* 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
*
* 화면이 파라미터 없이 GET 으로 최근 결과 맵을 받아 row.id 로 매칭한다(kginicis test-map 선례).
* params 로 로그 id 를 넘기지 않으므로 목록 data_source 로드 순서에 의존하지 않는다. 이력 화면은
* 페이지네이션(20건)이라 최근 N건 맵으로 현재 화면을 덮는다. notification_log_id 가 없는
* dispatch(아직 연결 전)는 제외한다.
*
* @param int $limit 최근 dispatch 조회 상한
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
*/
public function recentLinkedKeyed(int $limit = 1000): Collection;
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts;
use Illuminate\Support\Collection;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
/**
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결 Repository 계약.
*
* 알림톡 탭 연동 CRUD(계획서 §6-2, Phase 6)와 알림톡 발송 시 template_code 해석을
* 담당한다. Phase 4 는 계약과 기본 조회/저장을 제공한다.
*/
interface BizppurioNotificationBindingRepositoryInterface
{
/**
* 알림 유형+채널로 활성 연동을 조회합니다 (발송 시 template_code 해석).
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널 (기본 alimtalk)
* @return BizppurioNotificationBinding|null 매칭 연동 또는 null(미연결)
*/
public function findActive(string $notificationType, string $channel = 'alimtalk'): ?BizppurioNotificationBinding;
/**
* 채널의 모든 연동을 조회합니다 (알림톡 탭 목록).
*
* @param string $channel 채널 (기본 alimtalk)
* @return Collection<int, BizppurioNotificationBinding>
*/
public function allByChannel(string $channel = 'alimtalk'): Collection;
/**
* 알림 유형+채널 연동을 생성하거나 갱신합니다 (탭 편집 모달 저장).
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @param array<string, mixed> $data template_code / template_name / fallback_sms_enabled / is_active
* @return BizppurioNotificationBinding 저장된 연동
*/
public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding;
/**
* 알림 유형+채널 연동을 삭제합니다 (연동 해제).
*
* @param string $notificationType 코어 notification_definitions.type
* @param string $channel 채널
* @return void
*/
public function delete(string $notificationType, string $channel = 'alimtalk'): void;
}
@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Models\User;
use App\Notifications\BaseNotification;
use App\Notifications\GenericNotification;
use App\Services\NotificationDefinitionService;
use App\Services\NotificationTemplateService;
use App\Services\PluginSettingsService;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Str;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\NotificationSendSkippedException;
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
/**
* 코어 알림 시스템의 alimtalk 채널 드라이버 (계획서 §6-2·Phase 6).
*
* ChannelManager::extend('alimtalk', …)(ServiceProvider::boot)로 등록되어, 코어가
* `via()` 에서 'alimtalk' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Phase 3 까지는
* no-op 스텁이 등록돼 있었고(크래시 방지), 이 드라이버가 그 스텁을 실발송으로 교체한다.
*
* SmsChannelDriver 와 흐름은 같으나 핵심 차이는 "발송 본문·요소의 출처"다:
* - SMS: 코어 알림 템플릿(sms 채널) 본문을 그대로 발송한다.
* - 알림톡: 관리자가 알림톡 탭에서 연결한 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을
* 카카오 상세조회로 가져와 발송한다(B안). 비즈뿌리오 발송 API 는 완성본을 요구하므로
* templatecode 만으로는 안 되고, 카카오에 등록된 templateContent·buttons·quickReplies·
* title·header·item·itemHighlight·representLink 를 발송 형식으로 변환해 채운다.
* 연결(binding)이 없으면 발송하지 않는다(알림톡은 임의 본문 발송 불가 — 승인 템플릿 필수).
*
* 처리 흐름:
* 1. 알림 유형(type)으로 활성 binding 조회. 없으면 NotificationSendSkippedException.
* 2. 카카오 승인 템플릿 내용 조회(KakaoTemplateContentResolver, 캐시). 실패 시 동일 예외.
* 3. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약).
* 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper). 본문 비면 동일 예외.
* 5. refkey 생성 → payload 조립(버튼·요소는 extra, 대체발송 ON 시 resend/recontent 병합) →
* 이력 pending → SendMessageJob 위임.
*
* 1~4 단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라
* NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의
* catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌
* "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로
* 기록하는 문제 — 이슈 #28). 이 단계에선 알림톡 발송 자체가 일어나지 않으므로 SMS 대체발송도
* 트리거되지 않는다 — SMS 대체는 알림톡 payload 에 resend 로 병합되어, 알림톡이 접수된 뒤
* 비즈뿌리오 측 발송 실패(수신 거부·미가입 등)에만 작동한다(fallback_sms_enabled ON 시).
* 발송된 알림톡의 개별 실패는 비즈뿌리오 결과코드로 webhook·Job 이 이력에 기록한다(Phase 4).
*/
class AlimtalkChannelDriver
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (SmsChannelDriver 와 동일 계약) */
public const RECIPIENT_PHONE_KEY = '_recipient_phone';
/**
* @param NotificationTemplateService $templateService SMS 대체발송 본문(코어 alimtalk 템플릿) resolve
* @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용)
* @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화
* @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2)
* @param KakaoTemplateContentResolver $kakaoContent 카카오 승인 템플릿 내용 조회·캐시(B안)
* @param AlimtalkPayloadMapper $payloadMapper 카카오 내용 → 발송 형식 변환·치환(B안)
* @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용)
*/
public function __construct(
private readonly NotificationTemplateService $templateService,
private readonly NotificationDefinitionService $definitionService,
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly MessagePayloadBuilder $payloadBuilder,
private readonly BizppurioDispatchRepositoryInterface $dispatches,
private readonly DispatchLinkContext $linkContext,
private readonly KakaoTemplateContentResolver $kakaoContent,
private readonly AlimtalkPayloadMapper $payloadMapper,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지용).
*
* 정의 조회 실패·이름 미설정 시 코드값(type)을 그대로 반환한다(안전 폴백).
*
* @param string $type 알림 유형 코드값 (welcome 등)
* @return string 사람이 읽는 이름 또는 코드값
*/
private function resolveTypeLabel(string $type): string
{
try {
$label = $this->definitionService->resolve($type)?->getLocalizedName();
return $label !== null && $label !== '' ? $label : $type;
} catch (\Throwable $e) {
return $type;
}
}
/**
* 알림을 카카오 알림톡으로 발송합니다.
*
* Laravel NotificationSender 가 'alimtalk' 채널 드라이버로 이 메서드를 호출한다.
* GenericNotification 이 아닌 알림은 대상이 아니므로 조용히 무시한다.
*
* @param object $notifiable 수신자 (User 또는 GuestNotifiable)
* @param Notification $notification 발송 대상 알림
*/
public function send(object $notifiable, Notification $notification): void
{
if (! $notification instanceof GenericNotification) {
return;
}
$type = $notification->getType();
// 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송)
// 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를
// channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다.
$binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value);
if ($binding === null) {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_binding_missing', ['type' => $this->resolveTypeLabel($type)])
);
}
// 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로,
// templatecode 만으로는 안 되고 카카오에 등록된 실제 내용을 가져와 채운다(B안).
// 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다.
$kakaoContent = $this->kakaoContent->resolve($binding->template_code);
if ($kakaoContent === null) {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_kakao_content_unavailable', ['type' => $this->resolveTypeLabel($type)])
);
}
// 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone)
$to = $this->resolvePhone($notifiable, $notification->getData());
if ($to === null) {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)])
);
}
// 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip.
$mapped = $this->payloadMapper->map($kakaoContent, $notification->getData());
$message = (string) ($mapped['message'] ?? '');
if (trim($message) === '') {
throw new NotificationSendSkippedException(
__('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)])
);
}
// 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합)
$refkey = $this->generateRefkey();
$payload = $this->payloadBuilder->buildAlimtalk(
$to,
$binding->template_code,
$message,
$refkey,
(array) ($mapped['extra'] ?? []),
);
if ($binding->fallback_sms_enabled) {
$payload = $this->withSmsFallback($payload, $this->smsFallbackBody($type, $notifiable, $notification));
}
// 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신.
$this->dispatches->create([
'refkey' => $refkey,
'channel' => DispatchChannel::Alimtalk->value,
'to_number' => $to,
'to_name' => $notifiable->name ?? null,
'to_user_id' => $this->resolveUserId($notifiable),
'content' => $message,
'request_payload' => $this->payloadBuilder->forHistory($payload),
'notification_type' => $type,
'status' => DispatchStatus::Pending->value,
'source' => DispatchSource::Auto->value,
'is_test_mode' => $this->isTestMode(),
'sent_at' => now(),
]);
// A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록
// refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다.
$this->linkContext->remember($refkey);
SendMessageJob::dispatch($payload, $refkey);
}
/**
* SMS 대체발송 본문을 코어 alimtalk 템플릿에서 렌더합니다 (B안).
*
* 알림톡 본문은 카카오 승인 템플릿에서 오지만, 실패 시 대체할 SMS 본문은 카카오와 무관하므로
* 코어 알림 템플릿(alimtalk 채널) 본문을 그대로 재사용한다. 코어 템플릿이 없거나 비활성이면
* 빈 문자열을 반환하고, withSmsFallback 이 빈 본문이면 대체를 병합하지 않는다(엣지 C2).
*
* @param string $type 알림 유형
* @param object $notifiable 수신자
* @param GenericNotification $notification 발송 대상 알림
* @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열)
*/
private function smsFallbackBody(string $type, object $notifiable, GenericNotification $notification): string
{
$template = $this->templateService->resolve($type, DispatchChannel::Alimtalk->value);
if ($template === null || ! $template->is_active) {
return '';
}
$locale = BaseNotification::resolveNotifiableLocale($notifiable);
$rendered = $template->replaceVariables($notification->getData(), $locale);
return (string) ($rendered['body'] ?? '');
}
/**
* 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송, 계획서 §6-2).
*
* 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. 대체 SMS 본문은
* 코어 알림 본문(치환 완료 텍스트)을 재사용한다. 부록 C-2 의 `resend:{first:"sms"}` +
* `recontent:{sms:{message}}` 구조를 따른다. 대체 본문이 비어 있으면(코어 템플릿 부재)
* 빈 SMS 를 보내지 않도록 병합하지 않는다(엣지 C2).
*
* @param array<string, mixed> $payload 알림톡 발송 payload
* @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용)
* @return array<string, mixed> resend/recontent 가 병합된 payload (빈 본문이면 원본 그대로)
*/
private function withSmsFallback(array $payload, string $renderedBody): array
{
if (trim($renderedBody) === '') {
return $payload;
}
$payload['resend'] = ['first' => 'sms'];
$payload['recontent'] = ['sms' => ['message' => $renderedBody]];
return $payload;
}
/**
* 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다.
*
* @param object $notifiable 수신자
* @return int|null 회원 ID 또는 null
*/
private function resolveUserId(object $notifiable): ?int
{
if ($notifiable instanceof User) {
return (int) $notifiable->getKey();
}
return null;
}
/**
* 수신자의 전화번호를 해석합니다 (SmsChannelDriver 와 동일 규칙).
*
* 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다.
* 숫자 외 문자는 제거하고, 값이 없으면 null 을 반환한다.
*
* @param object $notifiable 수신자
* @param array<string, mixed> $data 알림 data
* @return string|null 정규화된 전화번호 또는 null
*/
private function resolvePhone(object $notifiable, array $data): ?string
{
$raw = $notifiable->mobile
?? ($data[self::RECIPIENT_PHONE_KEY] ?? null);
$normalized = preg_replace('/[^0-9]/', '', (string) $raw);
return ($normalized === null || $normalized === '') ? null : $normalized;
}
/**
* webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다.
*
* @return string 32자 refkey
*/
private function generateRefkey(): string
{
return Str::random(32);
}
/**
* 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용).
*
* 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책).
*
* @return bool 검수 모드면 true
*/
private function isTestMode(): bool
{
return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true);
}
}
@@ -0,0 +1,297 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
/**
* 카카오 상세조회 응답 → 비즈뿌리오 발송 API content.at 변환기 (B안 5-2).
*
* 카카오 관리 API(kapi) 의 템플릿 필드는 발송 API(/v3/message) 의 필드와 이름이 다르다
* (예: kapi `buttons[].linkMo` ↔ 발송 `button[].url_mobile`). 이 매퍼는 상세조회 응답을
* 발송 규격으로 변환하면서, 각 필드의 카카오 변수(#{var})를 알림 data 로 치환한다.
* 발송 API 는 완성본을 요구하므로(카카오가 변수를 대신 치환하지 않음) 이 단계가 필수다.
*
* 변환 대상(매뉴얼 5.메시지발송 알림톡 at):
* - templateContent → message
* - buttons[] → button[] (linkType→type, linkMo→url_mobile, …)
* - quickReplies[] → quickreply[]
* - templateTitle → title
* - templateHeader → header
* - templateItem → item (list/summary 구조 유지 + 치환)
* - templateItemHighlight → itemhighlight
* - templateRepresentLink → link (linkMo→url_mobile, …)
*
* 부재/빈 필드는 발송 payload 에 넣지 않는다(방어적 — 조건부 필수 필드는 템플릿에 있을 때만
* 채워야 하고, 없는 필드를 빈 값으로 넣으면 카카오가 거부한다).
*
* message 를 제외한 button/quickreply/title/header/item/itemhighlight/link 는 `extra` 키에
* 모아 반환한다. 호출측(AlimtalkChannelDriver)이 MessagePayloadBuilder::buildAlimtalk 의
* $extra 인자로 그대로 넘긴다.
*/
class AlimtalkPayloadMapper
{
/**
* 버튼/바로연결/대표링크의 카카오 링크 필드 → 발송 필드 매핑.
*
* @var array<string, string>
*/
private const LINK_FIELD_MAP = [
'linkMo' => 'url_mobile',
'linkPc' => 'url_pc',
'linkAnd' => 'scheme_android',
'linkIos' => 'scheme_ios',
];
/**
* 카카오 상세조회 응답을 발송 API content.at 형식으로 변환합니다.
*
* @param array<string, mixed> $detail 카카오 상세조회 응답(templateContent/buttons/…)
* @param array<string, mixed> $data 알림 data (변수 치환 소스: {key => value})
* @return array{message: string, extra: array<string, mixed>} 치환 완료 본문 + extra
*/
public function map(array $detail, array $data): array
{
$message = $this->substitute((string) ($detail['templateContent'] ?? ''), $data);
$extra = [];
if ($button = $this->mapButtons($detail['buttons'] ?? null, $data)) {
$extra['button'] = $button;
}
if ($quickreply = $this->mapButtons($detail['quickReplies'] ?? null, $data)) {
$extra['quickreply'] = $quickreply;
}
if ($title = $this->substitute((string) ($detail['templateTitle'] ?? ''), $data)) {
$extra['title'] = $title;
}
if ($header = $this->substitute((string) ($detail['templateHeader'] ?? ''), $data)) {
$extra['header'] = $header;
}
if ($item = $this->mapItem($detail['templateItem'] ?? null, $data)) {
$extra['item'] = $item;
}
if ($highlight = $this->mapItemHighlight($detail['templateItemHighlight'] ?? null, $data)) {
$extra['itemhighlight'] = $highlight;
}
if ($link = $this->mapLinkFields($detail['templateRepresentLink'] ?? null, $data)) {
$extra['link'] = $link;
}
return ['message' => $message, 'extra' => $extra];
}
/**
* 버튼/바로연결 배열을 발송 형식으로 변환합니다 (동일 규칙 공유).
*
* @param mixed $buttons 카카오 buttons/quickReplies 배열
* @param array<string, mixed> $data 변수 치환 소스
* @return array<int, array<string, mixed>> 발송 button/quickreply 배열 (없으면 빈 배열)
*/
private function mapButtons(mixed $buttons, array $data): array
{
if (! is_array($buttons) || $buttons === []) {
return [];
}
$mapped = [];
foreach ($buttons as $button) {
if (! is_array($button)) {
continue;
}
$row = [];
if (isset($button['name'])) {
$row['name'] = $this->substitute((string) $button['name'], $data);
}
if (isset($button['linkType'])) {
$row['type'] = (string) $button['linkType'];
}
// 링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 필드 + URL 변수 치환.
foreach (self::LINK_FIELD_MAP as $from => $to) {
if (isset($button[$from]) && $button[$from] !== '') {
$row[$to] = $this->substituteLinkField((string) $button[$from], $data);
}
}
// 전화·플러그인 등 부가 필드.
if (isset($button['telNumber']) && $button['telNumber'] !== '') {
$row['tel_number'] = (string) $button['telNumber'];
}
if (isset($button['pluginId']) && $button['pluginId'] !== '') {
$row['plugin_id'] = (string) $button['pluginId'];
}
if ($row !== []) {
$mapped[] = $row;
}
}
return $mapped;
}
/**
* 대표링크/링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 형식으로 변환합니다.
*
* @param mixed $link 카카오 templateRepresentLink
* @param array<string, mixed> $data 변수 치환 소스
* @return array<string, mixed> 발송 link 필드 (없으면 빈 배열)
*/
private function mapLinkFields(mixed $link, array $data): array
{
if (! is_array($link) || $link === []) {
return [];
}
$mapped = [];
foreach (self::LINK_FIELD_MAP as $from => $to) {
if (isset($link[$from]) && $link[$from] !== '') {
$mapped[$to] = $this->substituteLinkField((string) $link[$from], $data);
}
}
return $mapped;
}
/**
* 아이템리스트(list/summary)를 변환하고 각 필드를 치환합니다.
*
* @param mixed $item 카카오 templateItem
* @param array<string, mixed> $data 변수 치환 소스
* @return array<string, mixed> 발송 item (없으면 빈 배열)
*/
private function mapItem(mixed $item, array $data): array
{
if (! is_array($item) || $item === []) {
return [];
}
$mapped = [];
if (isset($item['list']) && is_array($item['list'])) {
$list = [];
foreach ($item['list'] as $entry) {
if (! is_array($entry)) {
continue;
}
$list[] = [
'title' => $this->substitute((string) ($entry['title'] ?? ''), $data),
'description' => $this->substitute((string) ($entry['description'] ?? ''), $data),
];
}
if ($list !== []) {
$mapped['list'] = $list;
}
}
if (isset($item['summary']) && is_array($item['summary'])) {
$mapped['summary'] = [
'title' => $this->substitute((string) ($item['summary']['title'] ?? ''), $data),
'description' => $this->substitute((string) ($item['summary']['description'] ?? ''), $data),
];
}
return $mapped;
}
/**
* 아이템 하이라이트(title/description)를 변환하고 치환합니다.
*
* @param mixed $highlight 카카오 templateItemHighlight
* @param array<string, mixed> $data 변수 치환 소스
* @return array<string, mixed> 발송 itemhighlight (없으면 빈 배열)
*/
private function mapItemHighlight(mixed $highlight, array $data): array
{
if (! is_array($highlight) || $highlight === []) {
return [];
}
$mapped = [];
if (isset($highlight['title'])) {
$mapped['title'] = $this->substitute((string) $highlight['title'], $data);
}
if (isset($highlight['description'])) {
$mapped['description'] = $this->substitute((string) $highlight['description'], $data);
}
return $mapped;
}
/**
* 카카오 변수(#{key})를 알림 data 값으로 치환합니다.
*
* 카카오 템플릿 변수와 코어 알림 data 는 변수명 규칙이 동일하다(표기만 #{} vs {}).
* data 에 없는 변수는 원문(#{key})을 유지한다 — 카카오가 변수 불일치로 판단하게 두어,
* 우리가 임의로 빈 값을 채워 잘못된 발송을 하지 않는다.
*
* @param string $text 치환 대상(#{key} 포함)
* @param array<string, mixed> $data 변수 치환 소스
* @return string 치환된 문자열
*/
private function substitute(string $text, array $data): string
{
if ($text === '' || $data === []) {
return $text;
}
$replacements = [];
foreach ($data as $key => $value) {
if (is_scalar($value) || $value === null) {
$replacements['#{'.$key.'}'] = (string) $value;
}
}
return strtr($text, $replacements);
}
/**
* 버튼/링크 URL 필드를 치환하고, 프로토콜 중복을 방어합니다.
*
* 코어 알림 data 의 `*_url` 계열 변수(action_url 등)는 항상 `config('app.url')` 기반의
* 프로토콜 포함 완전한 URL 이다(mail 채널이 href="{action_url}" 로 원문 그대로 소비하는
* 계약). 반면 카카오 알림톡 버튼은 `http://#{action_url}` 처럼 원문에 프로토콜 접두어를
* 직접 붙여 등록되는 경우가 있어, 단순 문자열 치환 시 `http://https://…` 형태로 프로토콜이
* 중복될 수 있다.
*
* 원문이 `http://`/`https://` 로 시작 + 치환 결과가 (그 접두어를 제거했을 때) 다시
* `http://`/`https://` 로 시작하는 경우에만 원문의 선행 접두어를 제거한다. 변수가 이미
* 프로토콜 없이 등록된 경우(가이드 문서 원안)나, 변수값 자체가 프로토콜을 포함하지 않는
* 경우(상대경로 등)는 원문을 그대로 둔다 — 후자를 건드리면 오히려 필요한 접두어가 사라진다.
*
* @param string $text 치환 대상(#{key} 포함, 링크 필드 원문)
* @param array<string, mixed> $data 변수 치환 소스
* @return string 치환되고 프로토콜 중복이 제거된 문자열
*/
private function substituteLinkField(string $text, array $data): string
{
$substituted = $this->substitute($text, $data);
foreach (['http://', 'https://'] as $prefix) {
if (! str_starts_with($text, $prefix)) {
continue;
}
$afterPrefix = substr($substituted, strlen($prefix));
if (str_starts_with($afterPrefix, 'http://') || str_starts_with($afterPrefix, 'https://')) {
return $afterPrefix;
}
}
return $substituted;
}
}
@@ -0,0 +1,273 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 알림톡 템플릿 조회 서비스 (Phase 5).
*
* 카카오 관리 API(kapi.ppurio.com)를 BizppurioKakaoApiClient 로 위임하여 알림톡 템플릿을
* 실시간 조회한다(목록·상세·카테고리·발신프로필). 등록·수정·삭제·검수·상태변경은 비즈뿌리오
* 콘솔로 위임하며, 이 화면은 목록·상태·내용 조회 + 알림 연결만 담당한다. 템플릿은 DB 에
* 저장하지 않고 매 요청 실시간으로 조회한다(계획서 §6-3).
*
* 이 서비스는 serviceStatus(REG/REQ/REJ/RDY/ACT/DMT/STP/BLK)를 상태 배지로 매핑하는
* 도메인 로직을 담당한다(RDY/ACT 만 알림 연결 가능 상태).
*
* 발신프로필 키(senderKey)는 환경설정(sender_key)에서 가져오며, 미설정 시 조회 자체가
* 불가능하므로 화면은 readiness 로 사전 안내한다(§6-3).
*/
class AlimtalkTemplateService
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 목록 조회 기본 페이지 크기 */
private const DEFAULT_COUNT = 20;
/** kapi 결과코드: 요청한 데이터가 없음(검색 결과 0건 포함, 13.응답코드정의.md) */
private const NOT_FOUND_CODE = '508';
/**
* serviceStatus → 상태 배지 매핑.
*
* key = kapi serviceStatus, value = ['label_key' => lang key, 'variant' => 배지 색].
* variant 는 프론트가 배지 색상 클래스로 사용한다(green/yellow/red/gray/dark/purple).
*
* @var array<string, array{label_key: string, variant: string}>
*/
private const STATUS_BADGES = [
'RDY' => ['label_key' => 'sendable', 'variant' => 'green'],
'ACT' => ['label_key' => 'sendable', 'variant' => 'green'],
'REQ' => ['label_key' => 'inspecting', 'variant' => 'yellow'],
'REJ' => ['label_key' => 'rejected', 'variant' => 'red'],
'REG' => ['label_key' => 'uninspected', 'variant' => 'gray'],
'STP' => ['label_key' => 'stopped', 'variant' => 'dark'],
'BLK' => ['label_key' => 'blocked', 'variant' => 'dark'],
'DMT' => ['label_key' => 'dormant', 'variant' => 'purple'],
];
/**
* @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트
* @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key)
*/
public function __construct(
private readonly BizppurioKakaoApiClient $kakao,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 알림톡 템플릿 목록을 실시간 조회합니다.
*
* @param array<string, mixed> $filters status(templateStatus)·keyword·page·count
* @return array{templates: array<int, array<string, mixed>>, pagination: array<string, int>}
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시(결과코드 508 제외)
*/
public function list(array $filters = []): array
{
$params = [
'count' => (int) ($filters['count'] ?? self::DEFAULT_COUNT),
'page' => max(1, (int) ($filters['page'] ?? 1)),
];
if (! empty($filters['status'])) {
$params['templateStatus'] = (string) $filters['status'];
}
if (! empty($filters['keyword'])) {
$params['keyword'] = (string) $filters['keyword'];
}
$response = $this->kakao->getTemplateList($this->senderKey(), $params);
try {
$this->assertSuccess($response);
} catch (BizppurioApiException $e) {
// 508 = "요청한 데이터가 없음"(검색 결과 0건 포함). 카카오는 이 경우를 목록
// 조회 실패로 응답하지만, 실제로는 정상적인 빈 결과이므로 예외로 취급하지 않는다.
if ($e->getResultCode() === self::NOT_FOUND_CODE) {
return [
'templates' => [],
'pagination' => [
'total' => 0,
'total_page' => 1,
'current_page' => $params['page'],
'per_page' => $params['count'],
],
];
}
throw $e;
}
$rows = (array) ($response['data']['list'] ?? $response['data'] ?? []);
return [
'templates' => array_map(fn (array $row) => $this->decorate($row), $rows),
'pagination' => [
'total' => (int) ($response['totalCount'] ?? count($rows)),
'total_page' => (int) ($response['totalPage'] ?? 1),
'current_page' => (int) ($response['currentPage'] ?? $params['page']),
'per_page' => $params['count'],
],
];
}
/**
* 알림톡 템플릿 상세를 실시간 조회합니다.
*
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 배지·가능 액션이 부가된 템플릿 상세
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function detail(string $templateCode): array
{
$response = $this->kakao->getTemplateDetail($this->senderKey(), $templateCode);
$this->assertSuccess($response);
return $this->decorate((array) ($response['data'] ?? []));
}
/**
* 템플릿 등록에 사용할 카테고리 목록 전체를 조회합니다.
*
* @return array<int, array<string, mixed>> [{code, name, groupName}]
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function categories(): array
{
$response = $this->kakao->request('/v3/kakao/template/category/all');
$this->assertSuccess($response);
return array_values((array) ($response['data'] ?? []));
}
/**
* 발신프로필(사용중) 목록을 조회합니다.
*
* 규격(5.발신프로필관리): `/v3/kakao/profile/use` 응답의 data 는
* `{success: [...프로필], fail: [...조회실패]}` 2단 봉투다. 실제 발신프로필 목록은
* data.success 배열에 담기므로 그 배열을 반환한다(data 통째 반환 시 success/fail
* 껍데기가 소비처에 그대로 노출됨).
*
* @return array<int, array<string, mixed>> 발신프로필 목록(data.success)
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function senderProfiles(): array
{
$response = $this->kakao->getSenderProfiles();
$this->assertSuccess($response);
return array_values((array) ($response['data']['success'] ?? []));
}
/**
* 템플릿 행에 상태 배지를 부가합니다.
*
* serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다.
* RDY/ACT(승인) 상태만 알림 연결 가능하며, 프론트가 배지로 이를 안내한다.
*
* @param array<string, mixed> $row kapi 템플릿 행
* @return array<string, mixed> 배지가 부가된 행
*/
private function decorate(array $row): array
{
$status = (string) ($row['serviceStatus'] ?? $this->deriveStatus($row));
$badge = self::STATUS_BADGES[$status] ?? ['label_key' => 'unknown', 'variant' => 'gray'];
$row['service_status'] = $status;
$row['status_badge'] = [
// 프론트 $t() 가 해석하는 프론트 lang 키 형식(templates.status.*)으로 준다.
// 프론트(en/ko.json)에는 이 키만 존재하며, 백엔드 messages.php 네임스페이스
// (::messages.template.status.*)는 프론트에 없어 원문이 그대로 노출된다.
'label_key' => 'sirsoft-message_bizppurio.templates.status.'.$badge['label_key'],
'variant' => $badge['variant'],
];
return $row;
}
/**
* 상세 응답에서 serviceStatus 가 없을 때 status/inspectionStatus 로 상태를 추정합니다.
*
* 상세 조회는 serviceStatus 대신 status(S/A/R)+inspectionStatus(REG/REQ/REJ/APR)를
* 내려주므로, 목록과 동일한 배지 체계로 환원한다.
*
* @param array<string, mixed> $row kapi 템플릿 상세 행
* @return string serviceStatus 코드
*/
private function deriveStatus(array $row): string
{
$inspection = (string) ($row['inspectionStatus'] ?? '');
$status = (string) ($row['status'] ?? '');
$block = (bool) ($row['block'] ?? false);
$dormant = (bool) ($row['dormant'] ?? false);
return match (true) {
$block => 'BLK',
$dormant => 'DMT',
$inspection === 'REQ' => 'REQ',
$inspection === 'REJ' => 'REJ',
$inspection === 'APR' && $status === 'S' => 'STP',
$inspection === 'APR' && $status === 'A' => 'ACT',
$inspection === 'APR' => 'RDY',
default => 'REG',
};
}
/**
* 환경설정에서 발신프로필 키(sender_key)를 조회합니다.
*
* @return string 발신프로필 키
*
* @throws BizppurioApiException 미설정 시
*/
private function senderKey(): string
{
$settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? [];
$senderKey = (string) ($settings['sender_key'] ?? '');
if ($senderKey === '') {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.sender_key_missing'),
);
}
return $senderKey;
}
/**
* kapi 응답이 성공(200)이 아니면 message 를 담아 예외를 던집니다.
*
* @param array<string, mixed> $response kapi 응답
*
* @throws BizppurioApiException 실패 코드 시
*/
private function assertSuccess(array $response): void
{
if ($this->kakao->isSuccess($response)) {
return;
}
$message = (string) ($response['message'] ?? '');
$code = (string) ($response['code'] ?? '');
throw new BizppurioApiException(
$message !== ''
? $message
: __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
resultCode: $code !== '' ? $code : null,
);
}
}
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Services\PluginSettingsService;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 비즈뿌리오 발송 시스템(api.bizppurio.com) API 클라이언트.
*
* `/v3/message` 를 Bearer 토큰 인증으로 호출해 SMS/LMS/알림톡을 발송한다.
* 완성된 payload(MessagePayloadBuilder 조립)를 받아 전송하며, 응답 결과코드가
* 3002(토큰무효)/3005(인증정보무효)면 토큰을 재발급(BizppurioTokenService)한 뒤
* 1회 재시도한다. environment 설정에 따라 운영/검수 도메인을 분기한다(카카오 관리
* 시스템 kapi 와 달리 발송 시스템만 도메인 분기 대상).
*/
class BizppurioApiClient
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 운영 발송 도메인 */
private const HOST_LIVE = 'https://api.bizppurio.com';
/** 검수 발송 도메인 */
private const HOST_DEV = 'https://dev-api.bizppurio.com';
/** 토큰·인증 무효 결과코드 — forget 후 재발급 트리거 */
private const AUTH_INVALID_CODES = ['3002', '3005'];
/** 발송 응답 성공 결과코드 */
private const SUCCESS_CODE = '1000';
private const CONNECT_TIMEOUT_SECONDS = 5;
private const REQUEST_TIMEOUT_SECONDS = 20;
/**
* @param BizppurioTokenService $tokenService 발송 토큰 발급·캐시
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회
*/
public function __construct(
private readonly BizppurioTokenService $tokenService,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 완성된 발송 payload 를 `/v3/message` 로 전송합니다.
*
* 토큰·인증 무효(3002/3005) 응답 시 토큰을 재발급한 뒤 1회 재시도합니다.
*
* @param array<string, mixed> $payload MessagePayloadBuilder 가 조립한 발송 본문
* @return array<string, mixed> 응답 배열 (code/description/messagekey/refkey)
*
* @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시
*/
public function sendMessage(array $payload): array
{
$result = $this->postMessage($payload, $this->tokenService->getToken());
// 토큰·인증 무효 → 재발급 후 1회 재시도
if (in_array((string) ($result['code'] ?? ''), self::AUTH_INVALID_CODES, true)) {
$result = $this->postMessage($payload, $this->tokenService->refreshToken());
}
return $result;
}
/**
* 발송 응답 결과코드가 성공(1000)인지 판정합니다.
*
* @param array<string, mixed> $result sendMessage 응답
* @return bool 성공이면 true
*/
public function isSuccess(array $result): bool
{
return (string) ($result['code'] ?? '') === self::SUCCESS_CODE;
}
/**
* 단일 발송 요청을 수행합니다.
*
* @param array<string, mixed> $payload 발송 본문
* @param string $token Bearer 액세스 토큰
* @return array<string, mixed> 응답 배열
*
* @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시
*/
private function postMessage(array $payload, string $token): array
{
$response = $this->http()
->withHeaders([
'Authorization' => 'Bearer '.$token,
'Content-Type' => 'application/json; charset=utf-8',
])
->post($this->baseUrl().'/v3/message', $payload);
if ($response->failed()) {
// 비즈뿌리오는 실패 응답에도 code·description 을 담아준다(명세). 이를 추출해
// 예외에 실어, 발송 이력·로그에 "왜 실패했는지"(결과코드+사유)가 남도록 한다.
// body 가 없거나 파싱 불가하면 HTTP 상태만 보존한다.
$body = $response->json();
$code = is_array($body) ? (string) ($body['code'] ?? '') : '';
$description = is_array($body) ? (string) ($body['description'] ?? '') : '';
throw new BizppurioApiException(
$description !== ''
? $description
: __('sirsoft-message_bizppurio::messages.error.send_failed'),
resultCode: $code !== '' ? $code : null,
httpStatus: $response->status(),
);
}
$result = $response->json();
if (! is_array($result)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.invalid_response'),
httpStatus: $response->status(),
);
}
return $result;
}
/**
* 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다.
*
* @return string 베이스 URL (스킴 포함)
*/
private function baseUrl(): string
{
// 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은
// 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다.
$isTestMode = (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true);
return $isTestMode ? self::HOST_DEV : self::HOST_LIVE;
}
/**
* 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다.
*/
private function http(): PendingRequest
{
return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS)
->timeout(self::REQUEST_TIMEOUT_SECONDS);
}
}
@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Services\PluginSettingsService;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 비즈뿌리오 카카오 관리 시스템(kapi.ppurio.com) API 클라이언트.
*
* 발송 시스템(api.bizppurio.com)과 별개의 시스템으로, 알림톡 템플릿 등록·검수·조회
* 및 발신프로필 조회를 담당한다. 토큰 인증이 아니라 매 요청 body 에 bizId + apiKey
* 를 실어 인증하며, 도메인은 검수/운영 구분 없이 단일(kapi.ppurio.com)이다.
*
* 모든 요청은 POST + JSON 이며 응답은 `{code, message, data}` 봉투를 따른다.
* 성공 결과코드는 "200" 이다. 이 클래스는 공통 POST 위임(bizId/apiKey 자동 주입)과
* Phase 5·6 에서 소비하는 엔드포인트 메서드를 제공한다.
*/
class BizppurioKakaoApiClient
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 카카오 관리 시스템 단일 도메인 (검수/운영 구분 없음) */
private const BASE_URL = 'https://kapi.ppurio.com';
/** 카카오 관리 API 성공 결과코드 */
private const SUCCESS_CODE = '200';
private const CONNECT_TIMEOUT_SECONDS = 5;
private const REQUEST_TIMEOUT_SECONDS = 20;
/**
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회(bizId=bizppurio_id, apiKey)
*/
public function __construct(
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 발신프로필 목록을 조회합니다. (`/v3/kakao/profile/use`)
*
* @return array<string, mixed> 응답 배열 (code/message/data)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function getSenderProfiles(): array
{
return $this->post('/v3/kakao/profile/use');
}
/**
* 알림톡 템플릿 목록을 조회합니다. (`/v3/kakao/template/list`)
*
* @param string $senderKey 발신프로필 키
* @param array<string, mixed> $params 추가 조회 파라미터 (count/page/status 등)
* @return array<string, mixed> 응답 배열 (code/message/data)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function getTemplateList(string $senderKey, array $params = []): array
{
return $this->post('/v3/kakao/template/list', array_merge($params, [
'senderKey' => $senderKey,
]));
}
/**
* 알림톡 템플릿 상세를 조회합니다. (`/v3/kakao/template/detail`)
*
* @param string $senderKey 발신프로필 키
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 응답 배열 (code/message/data)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function getTemplateDetail(string $senderKey, string $templateCode): array
{
return $this->post('/v3/kakao/template/detail', [
'senderKey' => $senderKey,
'templateCode' => $templateCode,
]);
}
/**
* 카카오 관리 API 의 임의 엔드포인트를 호출합니다.
*
* Phase 5·6 의 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는
* 자동으로 주입되므로 도메인 파라미터만 전달한다.
*
* @param string $path 엔드포인트 경로 (예: '/v3/kakao/template/add')
* @param array<string, mixed> $params 요청 파라미터 (bizId/apiKey 제외)
* @return array<string, mixed> 응답 배열 (code/message/data)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function request(string $path, array $params = []): array
{
return $this->post($path, $params);
}
/**
* 카카오 관리 API 응답이 성공(200)인지 판정합니다.
*
* @param array<string, mixed> $result 응답 배열
* @return bool 성공이면 true
*/
public function isSuccess(array $result): bool
{
return (string) ($result['code'] ?? '') === self::SUCCESS_CODE;
}
/**
* bizId/apiKey 를 주입해 카카오 관리 API 를 POST 호출합니다.
*
* @param string $path 엔드포인트 경로
* @param array<string, mixed> $params 요청 파라미터 (bizId/apiKey 제외)
* @return array<string, mixed> 응답 배열
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
private function post(string $path, array $params = []): array
{
[$bizId, $apiKey] = $this->credentials();
$response = $this->http()
->withHeaders(['Content-Type' => 'application/json; charset=utf-8'])
->post(self::BASE_URL.$path, array_merge($params, [
'bizId' => $bizId,
'apiKey' => $apiKey,
]));
if ($response->failed()) {
throw $this->failureException($response);
}
$result = $response->json();
if (! is_array($result)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.invalid_response'),
httpStatus: $response->status(),
);
}
return $result;
}
/**
* HTTP 실패 응답에서 카카오가 준 사유(message)와 결과코드(code)를 추출해 예외를 만듭니다.
*
* 카카오 관리 API 는 실패 시에도 `{code, message}` 봉투를 반환하므로, body 를 파싱해
* 운영자에게 실제 사유(접근 불가 IP·반려 사유·계정 오류 등)를 노출한다. body 파싱이
* 불가하거나 message 가 비어 있으면 일반 실패 문구로 폴백한다.
*
* @param Response $response 실패한 HTTP 응답
* @return BizppurioApiException 카카오 사유·결과코드·HTTP 상태가 담긴 예외
*/
private function failureException(Response $response): BizppurioApiException
{
$body = $response->json();
$message = is_array($body) ? (string) ($body['message'] ?? '') : '';
$code = is_array($body) ? (string) ($body['code'] ?? '') : '';
return new BizppurioApiException(
$message !== ''
? $message
: __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
resultCode: $code !== '' ? $code : null,
httpStatus: $response->status(),
);
}
/**
* 환경설정에서 bizId(=bizppurio_id)와 apiKey 를 조회합니다.
*
* @return array{0: string, 1: string} [bizId, apiKey]
*
* @throws BizppurioApiException 자격증명 미설정 시
*/
private function credentials(): array
{
$settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? [];
$bizId = (string) ($settings['bizppurio_id'] ?? '');
$apiKey = (string) ($settings['api_key'] ?? '');
if ($bizId === '' || $apiKey === '') {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.kakao_credentials_missing'),
);
}
return [$bizId, $apiKey];
}
/**
* 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다.
*/
private function http(): PendingRequest
{
return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS)
->timeout(self::REQUEST_TIMEOUT_SECONDS);
}
}
@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Contracts\Extension\CacheInterface;
use App\Services\PluginSettingsService;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 비즈뿌리오 발송 시스템(api.bizppurio.com) 인증 토큰 서비스.
*
* `/v1/token` 을 Basic 인증(계정:암호 Base64)으로 호출해 Bearer 토큰을 발급받고,
* 확장 도메인 캐시(CacheInterface)에 저장한다. 토큰 유효 시간은 24시간이나, 만료
* 경계 재발급 리스크를 피하기 위해 23시간 TTL 로 캐시한다(PO 결정, 계획서 §5).
*
* 발송 응답이 3002(토큰무효)/3005(인증정보무효)를 반환하면 캐시를 forget 하고
* 1회 재발급한다(BizppurioApiClient 가 재시도 트리거). CacheInterface 는
* MessageBizppurioServiceProvider 의 $cacheServices contextual binding 으로 주입된다.
*/
class BizppurioTokenService
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 토큰 캐시 키 (확장 도메인 캐시 접두사가 자동 적용됨) */
private const CACHE_KEY = 'bizppurio:token';
/** 토큰 캐시 TTL (초) — 23시간. 유효 24h 대비 만료 경계 회피 */
private const CACHE_TTL_SECONDS = 23 * 3600;
/** 운영 발송 도메인 */
private const HOST_LIVE = 'https://api.bizppurio.com';
/** 검수 발송 도메인 */
private const HOST_DEV = 'https://dev-api.bizppurio.com';
private const CONNECT_TIMEOUT_SECONDS = 5;
private const REQUEST_TIMEOUT_SECONDS = 15;
/**
* @param CacheInterface $cache 확장 도메인 캐시 드라이버(contextual binding 주입)
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회
*/
public function __construct(
private readonly CacheInterface $cache,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 캐시된 토큰을 반환하거나, 없으면 새로 발급받아 캐시합니다.
*
* @return string Bearer 액세스 토큰
*
* @throws BizppurioApiException 토큰 발급 실패 시
*/
public function getToken(): string
{
return $this->cache->remember(
self::CACHE_KEY,
fn (): string => $this->issueToken(),
self::CACHE_TTL_SECONDS,
);
}
/**
* 캐시된 토큰을 무효화하고 새 토큰을 발급받아 반환합니다.
*
* 발송 응답이 3002/3005(토큰·인증 무효)일 때 호출한다.
*
* @return string 새로 발급받은 Bearer 액세스 토큰
*
* @throws BizppurioApiException 토큰 재발급 실패 시
*/
public function refreshToken(): string
{
$this->cache->forget(self::CACHE_KEY);
return $this->getToken();
}
/**
* 캐시된 토큰을 제거합니다.
*/
public function forget(): void
{
$this->cache->forget(self::CACHE_KEY);
}
/**
* 현재 저장된 자격증명으로 토큰 발급을 즉시 재검증합니다.
*
* 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출한다(관리자가 계정/비밀번호가
* 유효한지 그 자리에서 확인하려는 목적 — 설정 화면 "연결 확인" 버튼이 소비).
* 검증에 성공하면 새로 발급된 토큰으로 캐시를 갱신해, 확인 직후의 발송이 이
* 토큰을 그대로 재사용할 수 있게 한다(불필요한 재발급 방지).
*
* @return string 새로 발급받은 Bearer 액세스 토큰
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function verifyCredentials(): string
{
$token = $this->issueToken();
$this->cache->put(self::CACHE_KEY, $token, self::CACHE_TTL_SECONDS);
return $token;
}
/**
* `/v1/token` 을 호출해 새 토큰을 발급받습니다.
*
* @return string Bearer 액세스 토큰
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
private function issueToken(): string
{
$settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? [];
$account = (string) ($settings['bizppurio_id'] ?? '');
$password = (string) ($settings['password'] ?? '');
if ($account === '' || $password === '') {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.credentials_missing'),
);
}
$response = $this->http()
->withHeaders([
'Authorization' => 'Basic '.base64_encode($account.':'.$password),
'Content-Type' => 'application/json; charset=utf-8',
])
->post($this->baseUrl($settings).'/v1/token');
if ($response->failed()) {
throw new BizppurioApiException(
$this->describeFailure($response),
resultCode: (string) ($response->json('code') ?? '') ?: null,
httpStatus: $response->status(),
);
}
$token = (string) ($response->json('accesstoken') ?? '');
if ($token === '') {
throw new BizppurioApiException(
$this->describeFailure($response),
resultCode: (string) ($response->json('code') ?? '') ?: null,
httpStatus: $response->status(),
);
}
return $token;
}
/**
* 토큰 발급 실패 응답에서 비즈뿌리오 원문 사유를 담은 메시지를 만듭니다.
*
* 비즈뿌리오는 실패 시 `{"code": "3007", "description": "invalid password in
* bizppurio"}` 형태로 원인을 내려준다. 원문이 있으면 함께 노출해 운영자가 계정/
* 비밀번호 오류인지 서버 오류인지 즉시 구분할 수 있게 한다(고정 문구만으로는
* 원인 추적이 불가능했던 문제 대응).
*
* @param \Illuminate\Http\Client\Response $response 실패한 HTTP 응답
* @return string 사용자에게 보여줄 실패 메시지
*/
private function describeFailure(\Illuminate\Http\Client\Response $response): string
{
$description = (string) ($response->json('description') ?? '');
if ($description === '') {
return __('sirsoft-message_bizppurio::messages.error.token_issue_failed');
}
return __('sirsoft-message_bizppurio::messages.error.token_issue_failed_with_reason', [
'reason' => $description,
]);
}
/**
* 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다.
*
* @param array<string, mixed> $settings 플러그인 환경설정
* @return string 베이스 URL (스킴 포함)
*/
private function baseUrl(array $settings): string
{
// 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은
// 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다.
$isTestMode = (bool) ($settings['is_test_mode'] ?? true);
return $isTestMode ? self::HOST_DEV : self::HOST_LIVE;
}
/**
* 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다.
*/
private function http(): PendingRequest
{
return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS)
->timeout(self::REQUEST_TIMEOUT_SECONDS);
}
}
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
/**
* 발송 사이클 내 refkey ↔ 코어 알림 로그 연결을 잇는 request-scoped 컨텍스트 (A-2).
*
* 코어 알림 발송 한 사이클의 실행 순서는 다음과 같다(코드 확인):
* before_channel_send
* → 채널 드라이버 send() : bizppurio_dispatch pending 생성(refkey 부여) → 여기에 refkey 기록
* after_channel_send
* → 코어 NotificationLogListener : NotificationLog 생성(id 부여)
* → core.notification_log.after_log_sent / after_log_failed 훅
* → LinkNotificationLogListener : 여기서 최근 refkey 를 꺼내 그 dispatch 에 log id 기록
*
* 드라이버(send)와 링크 리스너(after_log_sent)는 같은 채널 발송 사이클 안에서 순차 실행되므로,
* "가장 최근에 부여한 refkey" 를 그 로그와 1:1 로 연결할 수 있다. 복합키(type+user+channel+
* 시각) 근사 매칭은 동일 시각 중복 발송 시 오매칭 위험이 있어 쓰지 않는다.
*
* 컨테이너에 싱글턴으로 바인딩되어 한 HTTP 요청/큐 잡 안에서만 상태를 공유한다.
* remember() 로 refkey 를 넣고 consume() 로 꺼낸다(꺼내면 비운다 — 다음 사이클로 새지 않게).
*/
class DispatchLinkContext
{
/** 이번 발송 사이클에서 드라이버가 방금 부여한 refkey (없으면 null). */
private ?string $pendingRefkey = null;
/**
* 드라이버가 dispatch pending 을 만든 직후 그 refkey 를 기록합니다.
*
* @param string $refkey 방금 부여한 refkey
*/
public function remember(string $refkey): void
{
$this->pendingRefkey = $refkey;
}
/**
* 링크 리스너가 최근 refkey 를 꺼냅니다 (꺼내면 비웁니다).
*
* 비-비즈뿌리오 채널(mail/database 등)의 로그가 발화한 경우엔 이 사이클에서 remember 된
* refkey 가 없으므로 null 을 반환한다(그 로그는 연결 대상 아님).
*
* @return string|null 최근 refkey 또는 null
*/
public function consume(): ?string
{
$refkey = $this->pendingRefkey;
$this->pendingRefkey = null;
return $refkey;
}
}
@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
/**
* 코어 알림 발송 이력 화면에 붙일 비즈뿌리오 발송 결과 조회 서비스 (A-2 표시 주입).
*
* 코어 화면이 현재 페이지의 코어 알림 로그 id 배열을 넘기면, 그 id 로 연결된 dispatch 를 일괄
* 조회해(N+1 회피) 로그 id 를 키로 하는 결과 맵을 만든다. 결과 코드 사유·분류는 ResultCodeResolver
* 를 재사용한다. 매칭되지 않는 로그(메일·DB 등 비-비즈뿌리오)는 맵에서 빠져 화면에서 빈 셀이 된다.
*
* 이 서비스는 표시용 파생 값(상태 라벨·`사유 (코드)`·잔액부족 여부·대체발송 라벨)만 계산하며,
* 전화번호 등 민감정보는 다루지 않는다(A-2 결과 컬럼엔 전화번호를 노출하지 않음).
*/
class DispatchResultService
{
/**
* @param BizppurioDispatchRepositoryInterface $dispatches dispatch 배치 조회
* @param ResultCodeResolver $resultCodes 결과 코드 사유·분류 해석
*/
public function __construct(
private readonly BizppurioDispatchRepositoryInterface $dispatches,
private readonly ResultCodeResolver $resultCodes,
) {}
/**
* 코어 알림 로그 id 배열에 대한 비즈뿌리오 결과 맵을 반환합니다.
*
* @param array<int, int> $notificationLogIds 코어 로그 id 목록(현재 페이지)
* @return array<int, array<string, mixed>> notification_log_id → 결과 표시 데이터
*/
public function resultsForLogIds(array $notificationLogIds): array
{
$dispatchMap = $this->dispatches->findByNotificationLogIdsKeyed($notificationLogIds);
return $this->buildResultMap($dispatchMap);
}
/**
* 최근 연결된 dispatch 결과를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
*
* 화면이 파라미터 없이 조회해 row.id 로 매칭한다(kginicis test-map 선례). 목록 data_source
* 로드 순서에 의존하지 않아 결과 컬럼이 빈 배열로 비는 문제를 원천 차단한다.
*
* @param int $limit 최근 dispatch 조회 상한
* @return array<int, array<string, mixed>> notification_log_id → 결과 표시 데이터
*/
public function recentResults(int $limit = 1000): array
{
return $this->buildResultMap($this->dispatches->recentLinkedKeyed($limit));
}
/**
* dispatch 맵을 log id 키의 결과 표시 배열 맵으로 변환합니다.
*
* @param \Illuminate\Support\Collection<int, BizppurioDispatch> $dispatchMap log id 키 dispatch 맵
* @return array<int, array<string, mixed>> notification_log_id → 결과 표시 데이터
*/
private function buildResultMap(\Illuminate\Support\Collection $dispatchMap): array
{
$results = [];
foreach ($dispatchMap as $logId => $dispatch) {
$results[(int) $logId] = $this->buildResult($dispatch);
}
return $results;
}
/**
* dispatch 1건을 화면 표시용 결과 배열로 변환합니다.
*
* @param BizppurioDispatch $dispatch 연결된 발송 이력
* @return array<string, mixed> 상태·결과 라벨·잔액부족·대체발송·검수 모드 표시 데이터
*/
private function buildResult(BizppurioDispatch $dispatch): array
{
$code = $dispatch->result_code;
$hasCode = $code !== null && $code !== '';
return [
'status' => $dispatch->status?->value,
'status_label' => $dispatch->status?->label(),
'result_code' => $code,
// `사유 (코드)` 표시 라벨. 코드가 없으면(리포트 미수신) null → 화면은 상태 라벨만 표시.
'result_label' => $hasCode ? $this->resultCodes->label($code) : null,
'is_low_balance' => $hasCode && $this->resultCodes->isBalanceLow($code),
'fallback_status' => $dispatch->fallback_status,
'channel' => $dispatch->channel?->value,
// 발송 시점 검수 모드 스냅샷. null=컬럼 신설 이전 이력(검수 여부 판단 불가 → 화면 미표시).
'is_test_mode' => $dispatch->is_test_mode,
// 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 notification_logs.body(대체발송용 코어
// 템플릿 본문)와 다른 값(카카오 승인 템플릿 실제 내용)이라, 코어 "본문" 표시만으로는
// 실제 발송 내용을 알 수 없다 — 화면이 채널별로 구분해 별도 노출할 수 있도록 포함.
'content' => $dispatch->content,
];
}
}
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Contracts\Extension\CacheInterface;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 카카오 승인 템플릿 내용(본문·버튼·요소)을 발송용으로 조회·캐시하는 서비스 (B안 5-1).
*
* 발송 시 알림톡 payload 는 카카오에 등록된 승인 템플릿의 실제 내용(templateContent·
* buttons·quickReplies·templateTitle·templateHeader·templateItem·templateItemHighlight·
* templateRepresentLink)으로 채워야 한다(비즈뿌리오 발송 API 가 완성본을 요구 — templatecode
* 는 식별용일 뿐 내용을 대신 채워주지 않는다). 이 서비스는 그 원천을 카카오 상세조회
* (AlimtalkTemplateService::detail)로 가져오되, template_code 단위로 캐시해 rate limit
* (1000회/합산)을 억제한다.
*
* 캐시 정책:
* - TTL 은 환경설정 template_cache_ttl(기본 3600초=1시간). 만료되면 다음 조회에서 자동
* 재조회되므로 카카오 템플릿 변경이 사람 개입 없이 최대 TTL 만큼 지연돼 반영된다.
* - TTL=0 이면 캐시를 우회하고 매 조회마다 실시간 조회한다(항상 최신 — 소량 발송·최신성
* 우선 사이트용).
* - 관리자가 카카오에서 템플릿을 방금 바꿔 즉시 반영이 필요하면 clear() 로 수동 무효화한다.
*
* 조회 실패(고아 template_code·카카오 장애·rate limit)는 예외를 삼키고 null 을 반환한다.
* 호출측(AlimtalkChannelDriver)이 null 을 받으면 알림톡 발송을 skip 하고 로그만 남긴다
* (발송이 접수되지 않으므로 SMS 대체발송은 트리거되지 않는다).
*/
class KakaoTemplateContentResolver
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 템플릿 내용 캐시 키 접두사 (확장 도메인 캐시 접두사가 추가로 적용됨) */
private const CACHE_KEY_PREFIX = 'bizppurio:tpl_content:';
/** 템플릿 내용 캐시 시간 기본값(분) — 1시간 */
private const DEFAULT_CACHE_MINUTES = 60;
/** rate limit(429/5002) 시 재시도 횟수 — 조회 폭주 완화(1회만, 무한 방지) */
private const RATE_LIMIT_RETRIES = 1;
/** rate limit 재시도 전 대기(마이크로초) — 0.2초 */
private const RATE_LIMIT_RETRY_WAIT_US = 200000;
/**
* @param AlimtalkTemplateService $templates 카카오 상세조회 위임
* @param CacheInterface $cache 확장 도메인 캐시(contextual binding 주입)
* @param PluginSettingsService $pluginSettings 캐시 TTL 설정 조회
*/
public function __construct(
private readonly AlimtalkTemplateService $templates,
private readonly CacheInterface $cache,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 카카오 승인 템플릿의 발송용 내용을 조회합니다.
*
* TTL>0 이면 캐시 우선(없으면 조회 후 캐시), TTL=0 이면 캐시 우회(매번 조회).
* 조회 실패는 null 을 반환한다.
*
* @param string $templateCode 카카오 템플릿 코드
* @return array<string, mixed>|null 카카오 상세(templateContent/buttons/…) 또는 실패 시 null
*/
public function resolve(string $templateCode): ?array
{
$ttl = $this->cacheTtl();
// TTL=0 → 캐시 우회, 매번 실시간 조회.
if ($ttl <= 0) {
return $this->fetch($templateCode);
}
$cacheKey = self::CACHE_KEY_PREFIX.$templateCode;
// 캐시 히트면 그대로 반환. 미스면 조회해 캐시.
$cached = $this->cache->get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$content = $this->fetch($templateCode);
// 조회 실패(null)는 캐시하지 않는다 — 다음 발송에서 재시도 가능하도록.
if ($content !== null) {
$this->cache->put($cacheKey, $content, $ttl);
}
return $content;
}
/**
* 특정 템플릿의 캐시를 무효화합니다 (관리자 수동 초기화).
*
* @param string $templateCode 카카오 템플릿 코드
*/
public function clear(string $templateCode): void
{
$this->cache->forget(self::CACHE_KEY_PREFIX.$templateCode);
}
/**
* 여러 템플릿의 캐시를 한 번에 무효화합니다 (관리자 전체 초기화).
*
* 캐시 키가 template_code 단위이므로, 연결된 모든 알림톡 템플릿 코드를 받아 각각 비운다.
* 다음 발송에서 최신 내용으로 재조회된다.
*
* @param array<int, string> $templateCodes 카카오 템플릿 코드 목록
* @return int 비운 캐시 키 수(중복 제거 후)
*/
public function clearMany(array $templateCodes): int
{
$codes = array_values(array_unique(array_filter($templateCodes, static fn ($c) => $c !== '')));
foreach ($codes as $code) {
$this->clear((string) $code);
}
return count($codes);
}
/**
* 카카오 상세조회를 수행합니다. 실패는 null(예외 삼킴 + 로그).
*
* rate limit(HTTP 429 또는 결과코드 5002)은 조회 폭주 상황이므로 짧게 대기 후 1회
* 재시도한다(무한 방지). 재시도해도 rate limit 이면 null 을 반환해 호출측이 발송을 skip
* 하게 한다. 그 외 실패(고아 template_code·자격증명 오류 등)는 재시도 없이 null.
*
* @param string $templateCode 카카오 템플릿 코드
* @return array<string, mixed>|null 상세 또는 실패 시 null
*/
private function fetch(string $templateCode): ?array
{
for ($attempt = 0; $attempt <= self::RATE_LIMIT_RETRIES; $attempt++) {
try {
return $this->templates->detail($templateCode);
} catch (BizppurioApiException $e) {
// rate limit 이고 재시도 여유가 남았으면 짧게 대기 후 재시도.
if ($this->isRateLimited($e) && $attempt < self::RATE_LIMIT_RETRIES) {
usleep(self::RATE_LIMIT_RETRY_WAIT_US);
continue;
}
Log::warning('비즈뿌리오 알림톡 템플릿 내용 조회 실패 — 발송 skip 후보', [
'template_code' => $templateCode,
'result_code' => $e->getResultCode(),
'http_status' => $e->getHttpStatus(),
'message' => $e->getMessage(),
]);
return null;
}
}
return null;
}
/**
* 예외가 rate limit(HTTP 429 또는 결과코드 5002)인지 판정합니다.
*
* @param BizppurioApiException $e 조회 실패 예외
* @return bool rate limit 이면 true
*/
private function isRateLimited(BizppurioApiException $e): bool
{
return $e->getHttpStatus() === 429 || $e->getResultCode() === '5002';
}
/**
* 캐시 TTL(초)을 환경설정에서 조회합니다 (기본 60분, 0=캐시 끔).
*
* 관리자 화면·저장은 "분" 단위(template_cache_minutes)로 관리하고, 캐시 드라이버에는
* 초 단위가 필요하므로 60 을 곱해 반환한다. 0 이면 캐시를 끄므로 0 그대로 반환한다.
*
* @return int TTL(초)
*/
private function cacheTtl(): int
{
$minutes = (int) $this->pluginSettings->get(
self::PLUGIN_IDENTIFIER,
'template_cache_minutes',
self::DEFAULT_CACHE_MINUTES,
);
return max(0, $minutes) * 60;
}
}
@@ -0,0 +1,174 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Services\PluginSettingsService;
/**
* 비즈뿌리오 발송(`/v3/message`) payload 조립기.
*
* SMS/LMS/알림톡(at) content 를 부록 C-2(매뉴얼 5.메시지발송) 구조로 조립한다.
* 공통 필드(account/from/to/refkey/type)와 채널별 content 를 결합하며, account 는
* bizppurio_id, from 은 sender_number, 알림톡 senderkey 는 sender_key 를 환경설정에서
* 읽는다. refkey(우리 부여 unique 키)와 수신번호·본문은 호출측(채널 드라이버)이 전달한다.
*
* 대체발송(resend/recontent)·예약(sendtime) 등 선택 필드는 1차 범위에서 채널
* 드라이버(Phase 3·6)가 필요 시 반환 배열에 병합한다. 이 빌더는 필수 골격만 만든다.
*/
class MessagePayloadBuilder
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/**
* @param PluginSettingsService $pluginSettings 플러그인 환경설정 조회
*/
public function __construct(
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* SMS 발송 payload 를 조립합니다. (message: EUC-KR 최대 90byte)
*
* @param string $to 수신 전화번호
* @param string $message 본문
* @param string $refkey 우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)
* @return array<string, mixed> 발송 payload
*/
public function buildSms(string $to, string $message, string $refkey): array
{
return $this->withCommon('sms', $to, $refkey, [
'sms' => [
'message' => $message,
],
]);
}
/**
* LMS 발송 payload 를 조립합니다. (message: EUC-KR 최대 2000byte, subject 최대 64byte)
*
* @param string $to 수신 전화번호
* @param string $message 본문
* @param string $refkey 우리 부여 키
* @param string|null $subject 제목(코어 subject 재사용). 없으면 생략
* @return array<string, mixed> 발송 payload
*/
public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array
{
$lms = ['message' => $message];
if ($subject !== null && $subject !== '') {
$lms['subject'] = $subject;
}
return $this->withCommon('lms', $to, $refkey, [
'lms' => $lms,
]);
}
/**
* 알림톡(AT) 발송 payload 를 조립합니다. (message: 한/영 1300자)
*
* senderkey 는 환경설정 sender_key 를 사용한다. 버튼/바로연결 등 유형별 선택
* 필드는 $extra 로 병합한다(템플릿에 포함된 경우 필수 — 채널 드라이버가 구성).
*
* @param string $to 수신 전화번호
* @param string $templateCode 카카오 템플릿 코드
* @param string $message 치환 완료된 본문 (#{변수} 형식)
* @param string $refkey 우리 부여 키
* @param array<string, mixed> $extra button/quickreply/title 등 유형별 선택 필드
* @return array<string, mixed> 발송 payload
*/
public function buildAlimtalk(
string $to,
string $templateCode,
string $message,
string $refkey,
array $extra = [],
): array {
$at = array_merge([
'senderkey' => $this->senderKey(),
'templatecode' => $templateCode,
'message' => $message,
], $extra);
return $this->withCommon('at', $to, $refkey, [
'at' => $at,
]);
}
/**
* 공통 필드(account/type/from/to/refkey)와 채널별 content 를 결합합니다.
*
* @param string $type 메시지 타입 (sms/lms/at)
* @param string $to 수신 전화번호
* @param string $refkey 우리 부여 키
* @param array<string, mixed> $content 채널별 content 배열
* @return array<string, mixed> 발송 payload
*/
private function withCommon(string $type, string $to, string $refkey, array $content): array
{
return [
'account' => $this->account(),
'type' => $type,
'from' => $this->senderNumber(),
'to' => $to,
'refkey' => $refkey,
'content' => $content,
];
}
/**
* 발송 payload 에서 발송 이력(request_payload) 저장용으로 개인식별 정보를 제거합니다.
*
* 이력 테이블에 이미 저장되는 필드(to→to_number, refkey→refkey, type→channel,
* content.{sms,lms,at}.message→content)는 완전 중복이므로 제외한다. 나머지(account/
* from/senderkey/templatecode/button 등 요소·resend/recontent)는 이력 테이블 어디에도
* 없고 발송 실패 원인 분석(결함①)·버튼 URL 검증(결함②)에 필요하므로 그대로 남긴다.
*
* @param array<string, mixed> $payload buildSms/buildLms/buildAlimtalk 가 조립한 발송 payload
* @return array<string, mixed> 개인식별 정보를 제거한 이력 저장용 payload
*/
public function forHistory(array $payload): array
{
unset($payload['to'], $payload['refkey'], $payload['type']);
foreach (['sms', 'lms', 'at'] as $channelKey) {
if (isset($payload['content'][$channelKey]['message'])) {
unset($payload['content'][$channelKey]['message']);
}
}
if (isset($payload['recontent']['sms']['message'])) {
unset($payload['recontent']['sms']['message']);
}
return $payload;
}
/**
* 발송 계정(account = bizppurio_id)을 반환합니다.
*/
private function account(): string
{
return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'bizppurio_id', '');
}
/**
* 발신 번호(from = sender_number)를 반환합니다.
*/
private function senderNumber(): string
{
return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_number', '');
}
/**
* 알림톡 발신프로필 키(senderkey = sender_key)를 반환합니다.
*/
private function senderKey(): string
{
return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_key', '');
}
}
@@ -0,0 +1,239 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
/**
* 이벤트↔알림톡 템플릿 연동(binding) 서비스 (계획서 §6-2, Phase 6 재설계 A).
*
* 알림 설정 알림톡 탭은 코어 기본 목록을 그대로 사용한다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체는
* 코어 목록 행 하단에 얹은 [연결/변경] 버튼 → 우리 연결 모달에서 입력하고, 우리 API
* (POST notification-bindings)로 이 서비스의 bind/unbind 를 직접 호출해 저장된다
* (코어 저장 버튼과 무관).
*
* 이 서비스는 (1) 연결 모달이 소비하는 조회 — 승인 템플릿 드롭다운(approvedTemplates)
* 과 현재 연동 맵(all) — 및 (2) 연결 모달 저장이 호출하는 bind/unbind 를 제공한다.
*
* 연결 대상 템플릿 드롭다운은 "발송 가능(승인) 상태" 템플릿만 노출한다 — 미승인 템플릿에
* 연동해도 발송이 거부되기 때문. 승인 판정은 AlimtalkTemplateService 의 배지 매핑(RDY/ACT
* = 발송가능)을 재사용한다.
*/
class NotificationBindingService
{
/** 연동 대상 채널 (1차 알림톡 고정) */
private const CHANNEL = 'alimtalk';
/** 발송 가능(연결 허용) 카카오 템플릿 상태 — RDY(발송전)·ACT(정상) */
private const SENDABLE_STATUSES = ['RDY', 'ACT'];
/**
* @param BizppurioNotificationBindingRepositoryInterface $bindings 연동 조회/저장
* @param AlimtalkTemplateService $templates 카카오 승인 템플릿 조회
* @param KakaoTemplateContentResolver $kakaoContent 발송용 템플릿 내용 캐시(수동 초기화 위임)
*/
public function __construct(
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly AlimtalkTemplateService $templates,
private readonly KakaoTemplateContentResolver $kakaoContent,
) {}
/**
* 알림톡 채널의 모든 연동을 notification_type → 연동 정보 맵으로 반환합니다.
*
* 코어 편집 모달 전용 칸이 편집 중인 알림의 기존 연동(연결 템플릿·SMS 대체)을 프리필하는
* 데 쓴다. 코어 목록은 코어가 렌더하므로(⚑⚑ 결정 A) 알림 정의와 조인하지 않고 binding
* 만 내려준다. 편집 모달은 def.type 으로 이 맵을 조회한다.
*
* $withAvailability=true 이면(알림톡 탭 진입 표시용) 카카오 승인 목록을 1회 조회해 각 연동에
* is_unavailable(연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가) 플래그를 부여한다.
* 저장 응답(store)처럼 프리필만 필요한 경로는 기본값(false)으로 호출해 카카오 조회를 생략한다.
*
* @param bool $withAvailability 카카오 승인 목록과 대조해 소실 여부를 부여할지 (표시용)
* @return array<string, array<string, mixed>> notification_type 키의 연동 맵
*/
public function all(bool $withAvailability = false): array
{
// 소실 판정 대상 = 발송 가능한(승인) 카카오 템플릿 코드 집합. 조회 실패(카카오 장애·자격증명
// 미설정)면 null 을 반환해 판정 자체를 건너뛴다 — 살아 있는 연동이 일시 장애로 "사용 불가"로
// 오탐되어 화면이 전부 경고로 물드는 것을 막는다(발송 시점 판정과 동일한 안전측 기준).
$sendableCodes = $withAvailability ? $this->sendableTemplateCodesOrNull() : null;
return $this->bindings->allByChannel(self::CHANNEL)
->keyBy('notification_type')
->map(function (BizppurioNotificationBinding $binding) use ($sendableCodes) {
$info = [
'notification_type' => $binding->notification_type,
'template_code' => $binding->template_code,
'template_name' => $binding->template_name,
'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled,
];
// 승인 목록 조회에 성공했을 때만 소실 여부를 부여한다(null=판정 생략).
if ($sendableCodes !== null) {
$info['is_unavailable'] = ! in_array($binding->template_code, $sendableCodes, true);
}
return $info;
})
->all();
}
/**
* 발송 가능한(승인) 카카오 템플릿 코드 집합을 반환하되, 조회 실패 시 null 을 반환합니다.
*
* approvedTemplates() 는 자격증명 미설정·카카오 장애 시 BizppurioApiException 을 던진다. 소실
* 판정은 "표시 부가정보"이므로 실패를 삼키고 null 을 반환해, 호출부가 판정을 건너뛰게 한다.
*
* @return array<int, string>|null 승인 template_code 목록(성공) 또는 null(조회 실패)
*/
private function sendableTemplateCodesOrNull(): ?array
{
try {
return array_column($this->approvedTemplates(), 'template_code');
} catch (BizppurioApiException) {
return null;
}
}
/**
* 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운).
*
* 카카오 템플릿 목록 중 serviceStatus 가 RDY/ACT 인 항목만 노출한다. 미승인 템플릿에
* 연동해도 발송이 거부되므로 애초에 선택지에서 제외한다.
*
* @return array<int, array{template_code: string, template_name: string}>
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function approvedTemplates(): array
{
// 승인 상태 필터는 kapi 에 status 파라미터로 위임하지 않고(상태 2종 조회 불가), 전체를
// 받아 serviceStatus 로 거른다. 페이지네이션 대신 넉넉한 count 로 1회 조회한다.
$result = $this->templates->list(['count' => 100]);
return collect($result['templates'] ?? [])
->filter(fn (array $row) => in_array((string) ($row['serviceStatus'] ?? $row['status'] ?? ''), self::SENDABLE_STATUSES, true))
->map(fn (array $row) => [
'template_code' => (string) ($row['templateCode'] ?? $row['code'] ?? ''),
'template_name' => (string) ($row['templateName'] ?? $row['name'] ?? ''),
])
->filter(fn (array $row) => $row['template_code'] !== '')
->values()
->all();
}
/**
* 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장).
*
* 저장 전 카카오 승인 상태(RDY/ACT)를 재검증한다 — 드롭다운이 승인 템플릿만 보여주지만
* 그 필터는 화면 단계일 뿐이라, API 를 직접 호출하면 미승인 템플릿도 저장될 수 있었다(회귀).
* 카카오 조회 자체가 실패(장애·자격증명 미설정)하면 승인 여부를 판정할 수 없으므로 안전측으로
* 저장을 거부한다 — 조회 실패를 "승인됨"으로 잘못 해석해 미승인 템플릿이 새는 것을 막는다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param array<string, mixed> $data template_code / template_name / fallback_sms_enabled
* @return BizppurioNotificationBinding 저장된 연동
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
*/
public function bind(string $notificationType, array $data): BizppurioNotificationBinding
{
$templateCode = (string) $data['template_code'];
$this->assertSendable($templateCode);
return $this->bindings->upsert($notificationType, self::CHANNEL, [
'template_code' => $templateCode,
'template_name' => (string) $data['template_name'],
'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false),
'is_active' => true,
]);
}
/**
* 템플릿 코드가 발송 가능(승인) 상태인지 검증합니다. 아니면 예외를 던집니다.
*
* @param string $templateCode 검증할 카카오 템플릿 코드
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
*/
private function assertSendable(string $templateCode): void
{
$sendableCodes = array_column($this->approvedTemplates(), 'template_code');
if (! in_array($templateCode, $sendableCodes, true)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.template_not_sendable', ['code' => $templateCode]),
);
}
}
/**
* 알림의 알림톡 연동을 해제(삭제)합니다.
*
* @param string $notificationType 코어 notification_definitions.type
*/
public function unbind(string $notificationType): void
{
$this->bindings->delete($notificationType, self::CHANNEL);
}
/**
* 연결된 모든 알림톡 템플릿의 발송용 내용 캐시를 초기화합니다 (관리자 수동 갱신).
*
* 카카오에서 템플릿 내용을 방금 바꿔 캐시 만료(기본 1시간)를 기다리지 않고 즉시 반영하고
* 싶을 때 사용한다. 연결(binding)된 template_code 를 모아 각 캐시를 비우면 다음 발송에서
* 최신 내용으로 재조회된다.
*
* @return int 초기화한 캐시 키 수(연결된 고유 template_code 수)
*/
public function clearTemplateContentCache(): int
{
$codes = $this->bindings->allByChannel(self::CHANNEL)
->pluck('template_code')
->all();
return $this->kakaoContent->clearMany($codes);
}
/**
* 연결 모달이 넘긴 값으로 연동을 반영합니다.
*
* 우리 연결 저장 API(NotificationBindingController::store → POST notification-bindings)에서
* 호출된다. 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제"
* 규칙 덕분에 연결 모달에서 드롭다운을 "연결 안 함"으로 바꾸고 저장하면 저장 한 번으로
* 해제까지 처리된다.
*
* @param string $notificationType 코어 notification_definitions.type
* @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제)
* @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용)
* @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부
*
* @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 (해제 시에는 미발생)
*/
public function applyFromTemplateSave(
string $notificationType,
?string $templateCode,
?string $templateName,
bool $fallbackSmsEnabled,
): void {
$code = trim((string) $templateCode);
if ($code === '') {
$this->unbind($notificationType);
return;
}
$this->bind($notificationType, [
'template_code' => $code,
'template_name' => trim((string) $templateName),
'fallback_sms_enabled' => $fallbackSmsEnabled,
]);
}
}
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use Plugins\Sirsoft\MessageBizppurio\Enums\ResultCategory;
/**
* 비즈뿌리오 결과 코드 해석기 (계획서 D11).
*
* 발송 응답·webhook 리포트의 결과 코드를 (1) 성공/실패/재시도/잔액부족으로 분류하고,
* (2) result_codes lang 으로 사람이 읽는 사유로 변환한다. 표시는 `사유 (코드)` 형식
* (예: "음영 지역 (4400)"). lang 에 없는 코드는 코드만 노출한다.
*
* 분류 상수는 매뉴얼(부록 C-3) 1차 범위 기준이며, lang/{ko,en}/result_codes.php 와
* 함께 유지한다.
*/
class ResultCodeResolver
{
/**
* 성공 코드 — 발송 응답 1000 / 리포트 SMS 4100 · LMS 6600 · 알림톡 7000 / 카카오 관리 200.
*/
private const SUCCESS_CODES = ['1000', '4100', '6600', '7000', '200'];
/**
* 재시도(일시 오류) 코드 — 큐가 재시도해야 하는 코드.
*
* 공통 일시오류(5002 요청 과다·5003/5004/5005 게이트웨이 오류·9000 알 수 없는 오류·
* 3011 비즈뿌리오 내부 오류·3013 미완료 메시지)에 더해, 알림톡 일시오류(7306 카카오
* 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 포함한다.
* 7305(성공 불확실)는 이미 발송됐을 수 있어 중복발송 위험이 있으므로 제외한다.
* 출처: 비즈뿌리오 공식 응답코드(bizppurio.github.io/response-codes).
*/
private const RETRYABLE_CODES = ['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'];
/**
* 잔액 부족 코드 — 선불 잔액부족 9070 / 알림톡 지갑 잔액부족 7436 /
* 후불 한도초과 9071 (D3 자체 알림 대상).
*
* 셋 다 "잔액·한도 소진으로 발송 불가" 성격이라 동일하게 관리자 자체 알림을 발화한다.
* 표시 사유는 result_codes lang 으로 각각 구분된다(9071 = 후불 한도 초과).
*/
private const BALANCE_LOW_CODES = ['9070', '7436', '9071'];
/**
* 결과 코드를 카테고리로 분류합니다.
*
* @param string $code 결과 코드
* @return ResultCategory 성공/재시도/잔액부족/영구실패
*/
public function categorize(string $code): ResultCategory
{
if (in_array($code, self::SUCCESS_CODES, true)) {
return ResultCategory::Success;
}
if (in_array($code, self::BALANCE_LOW_CODES, true)) {
return ResultCategory::BalanceLow;
}
if (in_array($code, self::RETRYABLE_CODES, true)) {
return ResultCategory::Retry;
}
return ResultCategory::PermanentFailure;
}
/**
* 결과 코드가 성공인지 여부.
*
* @param string $code 결과 코드
* @return bool
*/
public function isSuccess(string $code): bool
{
return $this->categorize($code) === ResultCategory::Success;
}
/**
* 결과 코드가 잔액 부족인지 여부 (D3 자체 알림 트리거).
*
* @param string $code 결과 코드
* @return bool
*/
public function isBalanceLow(string $code): bool
{
return in_array($code, self::BALANCE_LOW_CODES, true);
}
/**
* 결과 코드의 사람이 읽는 사유(로케일)를 반환합니다.
*
* lang 에 정의된 코드면 사유를, 없으면 null 을 반환한다.
*
* @param string $code 결과 코드
* @return string|null 사유 또는 null(미정의)
*/
public function reason(string $code): ?string
{
$key = "sirsoft-message_bizppurio::result_codes.{$code}";
$reason = __($key);
// __() 는 미정의 시 키 문자열을 그대로 반환 → 미정의로 판정.
return $reason === $key ? null : $reason;
}
/**
* 표시용 라벨 `사유 (코드)` 을 반환합니다.
*
* lang 에 사유가 있으면 "사유 (코드)", 없으면 코드만 반환한다.
*
* @param string $code 결과 코드
* @return string 표시 라벨
*/
public function label(string $code): string
{
$reason = $this->reason($code);
return $reason === null ? $code : "{$reason} ({$code})";
}
}

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