From 77df8c9a7c3cd36e707f2dcc67ca1a80a6385bcd Mon Sep 17 00:00:00 2001 From: chym1217 Date: Tue, 14 Jul 2026 10:22:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(message=5Fbizppurio):=20=EB=B0=9C=EC=86=A1?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=A1=9D=20+=20webhook=20?= =?UTF-8?q?=EC=88=98=EC=8B=A0=20+=20=EA=B2=B0=EA=B3=BC=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문자·알림톡 발송 결과를 DB에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로 도달 여부를 갱신한다. 결과코드를 사유+분류로 해석하고, 잔액 부족 시 관리자에게 쿨다운(기본 1시간) 내 1회만 알린다. 발송 결과를 확인하는 화면은 플러그인 독립 페이지 대신 코어 알림 이력에 얹는 방식(후속)으로 이관하기로 하여, 이번에 만든 독립 이력 화면과 화면 전용 백엔드는 제거했다. webhook 결과 저장·수신·해석 뼈대는 유지한다. --- docs/backend/api/README.md | 4 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 2 + .../config/settings/defaults.json | 3 +- ...0001_create_bizppurio_dispatches_table.php | 69 +++++ ..._bizppurio_notification_bindings_table.php | 51 ++++ .../docs/api/README.md | 3 +- .../docs/api/report.md | 3 +- .../docs/api/webhook.md | 86 ++++++ .../lang/en/messages.php | 5 + .../lang/en/result_codes.php | 66 +++++ .../lang/ko/messages.php | 5 + .../lang/ko/result_codes.php | 69 +++++ .../src/Concerns/PreventsReplayWebhook.php | 43 +++ .../BizppurioWebhookController.php | 49 ++++ .../BizppurioWebhookIpWhitelist.php | 46 ++++ .../Http/Requests/BizppurioWebhookRequest.php | 49 ++++ .../src/Jobs/SendMessageJob.php | 39 ++- .../src/Models/BizppurioDispatch.php | 140 ++++++++++ .../Models/BizppurioNotificationBinding.php | 87 ++++++ .../MessageBizppurioServiceProvider.php | 12 +- .../BizppurioDispatchRepository.php | 90 +++++++ ...BizppurioNotificationBindingRepository.php | 79 ++++++ .../BizppurioDispatchRepositoryInterface.php | 51 ++++ ...NotificationBindingRepositoryInterface.php | 53 ++++ .../src/Services/ResultCodeResolver.php | 112 ++++++++ .../src/Services/SmsChannelDriver.php | 38 ++- .../src/Services/WebhookReportService.php | 146 ++++++++++ .../src/routes/api.php | 13 + .../Feature/Installation/InstallationTest.php | 44 ++++ .../Feature/Webhook/BizppurioWebhookTest.php | 249 ++++++++++++++++++ .../tests/Unit/Jobs/SendMessageJobTest.php | 53 +++- .../Unit/Models/BizppurioDispatchTest.php | 90 +++++++ .../BizppurioDispatchRepositoryTest.php | 75 ++++++ ...purioNotificationBindingRepositoryTest.php | 67 +++++ .../Unit/Services/ResultCodeResolverTest.php | 84 ++++++ .../Unit/Services/SmsChannelDriverTest.php | 42 +++ .../scenarios/dispatch-history-webhook.yaml | 73 +++++ 37 files changed, 2169 insertions(+), 21 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index ee08f795..34b2ebfd 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 373 +- **확장 수**: 10 · **엔드포인트 수**: 374 | 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 | | --- | --- | --- | --- | @@ -212,7 +212,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } | `sirsoft-ckeditor5` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 2 / 2 | | `sirsoft-gdpr` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 | | `sirsoft-marketing` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 | -| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 1 / 1 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 2 / 2 | | `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 22 | | `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 1 / 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index 67b9bc01..256ccfa2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -16,3 +16,5 @@ - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) +- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.) +- 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json index d4650b55..9fab46fb 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json @@ -9,7 +9,8 @@ "password": "", "api_key": "", "sender_number": "", - "sender_key": "" + "sender_key": "", + "balance_low_notify_cooldown": 3600 }, "frontend_schema": { "is_test_mode": { "expose": false }, diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php new file mode 100644 index 00000000..b9bfb832 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php @@ -0,0 +1,69 @@ +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->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null'); + $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->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'); + }); + + if (DB::getDriverName() === 'mysql') { + Schema::table('bizppurio_dispatches', function (Blueprint $table) { + $table->comment('비즈뿌리오 문자·알림톡 발송 이력'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bizppurio_dispatches'); + } +}; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php new file mode 100644 index 00000000..25e3ce49 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php @@ -0,0 +1,51 @@ +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', 30)->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'); + } +}; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index 4430a7a0..d223bcc9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,11 +4,12 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 1 · **엔드포인트 수**: 1 +- **문서 수**: 2 · **엔드포인트 수**: 2 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | | [report.md](report.md) | `report` | 1 | +| [webhook.md](webhook.md) | `webhook` | 1 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md index 9667ac02..3f8a4284 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md @@ -41,7 +41,7 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| url | string | `http://g7-issue.eh.test/api/plugins/s…` | 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 콜백 URL(`/api/plugins/sirsoft-message_bizppurio/webhook`)의 사이트 도메인 기준 절대 주소. `config('app.url')` 을 신뢰 소스로 조합하므로 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 반환된다. | +| url | string | `http://g7-issue.eh.test/api/plugins/s…` | | **응답 예시** @@ -77,3 +77,4 @@ URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합 ※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다. + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md new file mode 100644 index 00000000..ad7d5e9b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md @@ -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 + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.webhook` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController@handle` +- **인증/권한**: 공개 (인증 불필요) + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| DEVICE | body | string | 아니오 | max 20 | | +| CMSGID | body | string | 아니오 | max 64 | | +| MSGID | body | string | 아니오 | max 64 | | +| PHONE | body | string | 아니오 | max 20 | | +| MEDIA | body | string | 아니오 | max 10 | | +| RESULT | body | string | 예 | max 10 | | +| REFKEY | body | string | 예 | max 32 | | +| TELRES | body | string | 아니오 | max 10 | | +| KAORES | body | string | 아니오 | max 10 | | + +**요청 예시** + +```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` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +비즈뿌리오가 문자·알림톡 발송 결과를 URL PUSH 로 통보하는 리포트 수신 엔드포인트다. 운영자가 이 주소를 비즈뿌리오에 등록하면(환경설정 화면의 리포트 수신 주소), 발송 후 결과가 이 엔드포인트로 전송된다. + +- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403. +- **처리**: `REFKEY` 로 발송 이력을 조회한다. 없으면(위조/미매칭) 200 으로 흡수한다. 이미 리포트가 반영된 이력(`reported_at` 존재)이면 replay 로 판정해 멱등 처리한다. 그 외에는 `RESULT` 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 `media`·`fallback_status`·`raw_payload`·`reported_at` 을 기록한다. +- **잔액부족**: `RESULT` 가 9070(문자)/7436(알림톡)이면 이력을 실패로 뒤집고 관리자에게 자체 알림을 1회 발송한다. +- 응답은 항상 200 이다(비즈뿌리오가 실패 응답을 재전송하지 않도록). replay 멱등이 중복 처리를 막는다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index e4b28302..b59faed7 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -59,6 +59,11 @@ return [ '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.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php new file mode 100644 index 00000000..7f5a6ed4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php @@ -0,0 +1,66 @@ + 'Success', + '2000' => 'Invalid request parameter', + '3001' => 'Missing required parameter', + '3002' => 'Invalid token (expired/revoked)', + '3004' => 'Request not allowed', + '3005' => 'Invalid authentication', + '3006' => 'Account information error', + '3007' => 'Sender number not registered', + '3009' => 'Message type error', + '3011' => 'Temporary processing delay', + '3013' => 'Temporary system error', + '5003' => 'Temporary delivery error', + '5004' => 'Temporary delivery error', + '5005' => 'Temporary delivery error', + '9000' => 'Temporary system error', + '9070' => 'Insufficient balance (SMS)', + + // ── 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 rejection', + '4443' => 'Other error', + + // ── 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', + '7106' => 'Deleted sender key', + '7107' => 'Blocked sender key', + '7204' => 'Template mismatch', + '7206' => 'Unapproved template', + '7308' => 'Phone number error', + '7320' => 'Receiver blocked', + '7325' => 'Variable length exceeded', + '7436' => 'Insufficient wallet balance (Alimtalk)', + '7523' => 'Other error', +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index e6959a7a..dfe38c01 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -59,6 +59,11 @@ return [ 'sender_number_attribute' => '발신번호', ], + // webhook(URL PUSH) 리포트 수신 + 'webhook' => [ + 'received' => '리포트를 수신했습니다.', + ], + // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job) 'error' => [ 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php new file mode 100644 index 00000000..cab79a08 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php @@ -0,0 +1,69 @@ + '성공', + '2000' => '요청 파라미터 오류', + '3001' => '필수 파라미터 누락', + '3002' => '토큰 무효(만료·폐기)', + '3004' => '허용되지 않은 요청', + '3005' => '인증 정보 무효', + '3006' => '계정 정보 오류', + '3007' => '발신번호 미등록', + '3009' => '메시지 타입 오류', + '3011' => '일시적 처리 지연', + '3013' => '일시적 시스템 오류', + '5003' => '일시적 전송 오류', + '5004' => '일시적 전송 오류', + '5005' => '일시적 전송 오류', + '9000' => '일시적 시스템 오류', + '9070' => '잔액 부족(문자)', + + // ── SMS 리포트 ──────────────────────────────────────── + '4100' => '성공', + '4400' => '음영 지역', + '4401' => '전원 꺼짐', + '4402' => '저장 매체 초과', + '4410' => '잘못된 번호', + '4414' => '결번·정지', + '4420' => '기타 단말 오류', + '4430' => '스팸 수신 거부', + '4443' => '기타 오류', + + // ── LMS 리포트 ──────────────────────────────────────── + '6600' => '성공', + '6603' => '음영 지역', + '6604' => '전원 꺼짐', + '6606' => '잘못된 번호', + '6621' => '메시지 길이 초과', + '6641' => '스팸 차단', + + // ── 알림톡 리포트 ───────────────────────────────────── + '7000' => '성공', + '7106' => '삭제된 발신 키', + '7107' => '차단된 발신 키', + '7204' => '템플릿 불일치', + '7206' => '검수되지 않은 템플릿', + '7308' => '전화번호 오류', + '7320' => '수신 차단', + '7325' => '변수 길이 초과', + '7436' => '지갑 잔액 부족(알림톡)', + '7523' => '기타 오류', +]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php new file mode 100644 index 00000000..df306291 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php @@ -0,0 +1,43 @@ +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, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php new file mode 100644 index 00000000..633d3453 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php @@ -0,0 +1,49 @@ +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'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php new file mode 100644 index 00000000..00e7f4c0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php @@ -0,0 +1,46 @@ +ip(), self::ALLOWED_IPS, true)) { + abort(403, 'Forbidden'); + } + + return $next($request); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php new file mode 100644 index 00000000..6dbaaedc --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php @@ -0,0 +1,49 @@ + + */ + 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'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php index 244e9c38..c039f494 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php @@ -9,7 +9,9 @@ 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; @@ -55,17 +57,28 @@ class SendMessageJob implements ShouldQueue /** * 발송을 수행하고 결과코드에 따라 재시도/실패를 판정합니다. * + * 발송 응답으로 이력(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): void + public function handle(BizppurioApiClient $client, BizppurioDispatchRepositoryInterface $dispatches): void { $result = $client->sendMessage($this->payload); $code = (string) ($result['code'] ?? ''); if ($client->isSuccess($result)) { - // 발송 접수 성공. 이력 갱신(messagekey 저장)은 Phase 4 훅이 담당. + // 발송 접수 성공 → sent(최종 성공/실패는 webhook 리포트가 확정) + messagekey 저장. + $this->updateDispatch($dispatches, [ + 'status' => DispatchStatus::Sent->value, + 'messagekey' => $result['messagekey'] ?? null, + ]); + return; } @@ -76,7 +89,13 @@ class SendMessageJob implements ShouldQueue ); } - // 영구 실패: 재시도하지 않고 종료. 실패 이력 기록은 Phase 4 가 담당. + // 영구 실패: 재시도하지 않고 종료 + 실패 이력 기록. + $this->updateDispatch($dispatches, [ + 'status' => DispatchStatus::Failed->value, + 'result_code' => $code, + 'result_message' => $result['description'] ?? null, + ]); + Log::warning('비즈뿌리오 발송 영구 실패', [ 'refkey' => $this->refkey, 'code' => $code, @@ -84,6 +103,20 @@ class SendMessageJob implements ShouldQueue ]); } + /** + * refkey 로 발송 이력을 조회해 갱신합니다 (이력이 없으면 무시). + * + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리 + * @param array $data 갱신 데이터 + */ + private function updateDispatch(BizppurioDispatchRepositoryInterface $dispatches, array $data): void + { + $dispatch = $dispatches->findByRefkey($this->refkey); + if ($dispatch !== null) { + $dispatches->update($dispatch, $data); + } + } + /** * 재시도 사이 대기 시간(초)을 반환합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php new file mode 100644 index 00000000..2a8c5903 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php @@ -0,0 +1,140 @@ + + */ + protected $fillable = [ + 'refkey', + 'messagekey', + 'channel', + 'media', + 'to_number', + 'to_name', + 'to_user_id', + 'content', + 'notification_type', + 'status', + 'result_code', + 'result_message', + 'fallback_status', + 'source', + 'sent_at', + 'reported_at', + 'raw_payload', + ]; + + /** + * 속성 캐스팅 정의 + * + * @return array + */ + protected function casts(): array + { + return [ + 'channel' => DispatchChannel::class, + 'status' => DispatchStatus::class, + 'source' => DispatchSource::class, + 'to_user_id' => 'integer', + 'sent_at' => 'datetime', + 'reported_at' => 'datetime', + '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); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php new file mode 100644 index 00000000..9ab0a20f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php @@ -0,0 +1,87 @@ + + */ + protected $fillable = [ + 'notification_type', + 'channel', + 'template_code', + 'template_name', + 'fallback_sms_enabled', + 'is_active', + ]; + + /** + * 속성 캐스팅 정의 + * + * @return array + */ + 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); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 4594a71f..86e75ffd 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -7,8 +7,13 @@ namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; use Illuminate\Notifications\ChannelManager; use Illuminate\Notifications\Notification; +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\BizppurioTokenService; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; +use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService; /** * 비즈뿌리오 메시징 플러그인 서비스 프로바이더. @@ -29,18 +34,21 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * @var array */ protected array $repositories = [ - // Phase 4: BizppurioDispatchRepositoryInterface / BizppurioNotificationBindingRepositoryInterface + BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class, + BizppurioNotificationBindingRepositoryInterface::class => BizppurioNotificationBindingRepository::class, ]; /** * CacheInterface 가 필요한 서비스 (contextual binding). * - * Phase 2 에서 발송 토큰 캐시 서비스(BizppurioTokenService)를 등록한다. + * - BizppurioTokenService(Phase 2): 발송 토큰 캐시 + * - WebhookReportService(Phase 4): 잔액부족 알림 쿨다운(D3 중복 방지) * * @var array */ protected array $cacheServices = [ BizppurioTokenService::class, + WebhookReportService::class, ]; /** diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php new file mode 100644 index 00000000..8f52d1af --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php @@ -0,0 +1,90 @@ + $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 $data 갱신 데이터 + * @return BizppurioDispatch 갱신된 이력 + */ + public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch + { + $dispatch->fill($data)->save(); + + return $dispatch; + } + + /** + * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다. + * + * @param array $filters channel / status / date_from / date_to / keyword + * @param int $perPage 페이지당 건수 + * @return LengthAwarePaginator + */ + 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}%"); + }); + } + + return $query->orderByDesc('created_at')->paginate($perPage); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php new file mode 100644 index 00000000..ad1deff0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php @@ -0,0 +1,79 @@ +active() + ->byNotificationType($notificationType) + ->where('channel', $channel) + ->first(); + } + + /** + * 채널의 모든 연동을 조회합니다. + * + * @param string $channel 채널 + * @return Collection + */ + 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 $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(); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php new file mode 100644 index 00000000..c4ed0052 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php @@ -0,0 +1,51 @@ + $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 $data 갱신 데이터 + * @return BizppurioDispatch 갱신된 이력 + */ + public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch; + + /** + * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다 (이력 화면). + * + * @param array $filters channel / status / date_from / date_to / keyword + * @param int $perPage 페이지당 건수 + * @return LengthAwarePaginator + */ + public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php new file mode 100644 index 00000000..e48b7bf8 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php @@ -0,0 +1,53 @@ + + */ + public function allByChannel(string $channel = 'alimtalk'): Collection; + + /** + * 알림 유형+채널 연동을 생성하거나 갱신합니다 (탭 편집 모달 저장). + * + * @param string $notificationType 코어 notification_definitions.type + * @param string $channel 채널 + * @param array $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; +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php new file mode 100644 index 00000000..7a592863 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php @@ -0,0 +1,112 @@ +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})"; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index 4bd4bf0a..d9e3856e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Plugins\Sirsoft\MessageBizppurio\Services; +use App\Models\User; use App\Notifications\BaseNotification; use App\Notifications\GenericNotification; use App\Services\NotificationTemplateService; @@ -11,7 +12,10 @@ use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Log; 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\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; /** * 코어 알림 시스템의 sms 채널 드라이버. @@ -40,11 +44,13 @@ class SmsChannelDriver * @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별 * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립 + * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4) */ public function __construct( private readonly NotificationTemplateService $templateService, private readonly SmsTypeResolver $typeResolver, private readonly MessagePayloadBuilder $payloadBuilder, + private readonly BizppurioDispatchRepositoryInterface $dispatches, ) {} /** @@ -90,7 +96,7 @@ class SmsChannelDriver return; } - // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 → Job 위임 + // 4. refkey 생성 → SMS/LMS 판별 → payload 조립 $refkey = $this->generateRefkey(); $channel = $this->typeResolver->resolve($message); @@ -98,9 +104,39 @@ class SmsChannelDriver ? $this->payloadBuilder->buildLms($to, $message, $refkey, (string) ($rendered['subject'] ?? '')) : $this->payloadBuilder->buildSms($to, $message, $refkey); + // 5. 발송 이력 pending 생성(Phase 4) → Job 위임. Job 이 refkey 로 조회해 sent/failed 갱신. + $this->dispatches->create([ + 'refkey' => $refkey, + 'channel' => $channel->value, + 'to_number' => $to, + 'to_name' => $notifiable->name ?? null, + 'to_user_id' => $this->resolveUserId($notifiable), + 'content' => $message, + 'notification_type' => $type, + 'status' => DispatchStatus::Pending->value, + 'source' => DispatchSource::Auto->value, + 'sent_at' => now(), + ]); + SendMessageJob::dispatch($payload, $refkey); } + /** + * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다. + * + * @param object $notifiable 수신자 + * @return int|null 회원 ID 또는 null + */ + private function resolveUserId(object $notifiable): ?int + { + // User 모델만 회원 PK 로 취급. GuestNotifiable 은 DB 회원이 아니므로 null. + if ($notifiable instanceof User) { + return (int) $notifiable->getKey(); + } + + return null; + } + /** * 수신자의 전화번호를 해석합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php new file mode 100644 index 00000000..98b26299 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php @@ -0,0 +1,146 @@ + $report 검증된 리포트 (REFKEY/RESULT/MEDIA/TELRES/KAORES + raw) + */ + public function apply(array $report): void + { + $refkey = (string) ($report['REFKEY'] ?? ''); + $resultCode = (string) ($report['RESULT'] ?? ''); + + // ① REFKEY 매칭 — 없으면 위조/미매칭, 멱등 흡수 + $dispatch = $this->dispatches->findByRefkey($refkey); + if ($dispatch === null) { + return; + } + + // ② replay — 이미 리포트 반영된 이력이면 멱등 + if ($this->wasAlreadyReported($dispatch)) { + $this->logReplayDetected($refkey, $resultCode); + + return; + } + + // ③ 결과 분류 → 상태 전이 + 리포트 필드 기록 + $category = $this->resolver->categorize($resultCode); + $status = $category === ResultCategory::Success + ? DispatchStatus::Success + : DispatchStatus::Failed; + + $this->dispatches->update($dispatch, [ + 'status' => $status->value, + 'media' => $report['MEDIA'] ?? null, + 'result_code' => $resultCode, + 'result_message' => $this->resolver->reason($resultCode), + 'fallback_status' => $this->resolveFallbackStatus($report), + 'reported_at' => now(), + 'raw_payload' => $report['raw'] ?? $report, + ]); + + // ④ 잔액부족(9070/7436) → 관리자 자체 알림 (쿨다운 내 1회만, D3) + if ($category === ResultCategory::BalanceLow) { + $this->notifyBalanceLowOnce($resultCode, $dispatch->channel->value); + } + } + + /** + * 잔액부족 알림을 쿨다운 내 1회만 발송합니다 (D3 중복 방지). + * + * 잔액이 부족하면 발송건마다 9070/7436 이 쏟아지므로, 그때마다 알림을 보내면 + * 관리자에게 알림이 폭주한다. 채널별로 캐시 키를 두어, 쿨다운(기본 1시간) 동안 + * 이미 알렸으면 발송을 건너뛴다. 발송 이력의 실패 기록(③)은 이와 무관하게 매 건 + * 남으므로, 실패 내역 자체는 모두 보존된다. + * + * 쿨다운은 environment 설정(balance_low_notify_cooldown, 초)으로 조정 가능하며, + * 화면 노출 없이 설정 파일에서만 관리한다. 잔액을 충전하면 9070/7436 이 더 이상 + * 발생하지 않으므로 알림도 자연히 멈춘다. + * + * @param string $resultCode 결과 코드(9070 문자 / 7436 알림톡) + * @param string $channel 채널(sms / lms / alimtalk) + */ + private function notifyBalanceLowOnce(string $resultCode, string $channel): void + { + $cacheKey = "bizppurio:balance_low_notified:{$channel}"; + + if ($this->cache->has($cacheKey)) { + return; + } + + $cooldown = (int) $this->pluginSettings->get( + self::PLUGIN_IDENTIFIER, + 'balance_low_notify_cooldown', + self::DEFAULT_BALANCE_LOW_COOLDOWN, + ); + + $this->cache->put($cacheKey, true, $cooldown); + + HookManager::doAction( + 'sirsoft-message_bizppurio.balance.low', + $resultCode, + $channel, + ); + } + + /** + * 대체발송 결과를 해석합니다 (webhook TELRES/KAORES). + * + * @param array $report 리포트 + * @return string|null 대체발송 결과 코드 또는 null + */ + private function resolveFallbackStatus(array $report): ?string + { + $telres = $report['TELRES'] ?? null; + $kaores = $report['KAORES'] ?? null; + + return $telres !== null && $telres !== '' + ? (string) $telres + : ($kaores !== null && $kaores !== '' ? (string) $kaores : null); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 53aedba9..6e34e5b6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -1,6 +1,10 @@ withoutMiddleware([EnforceIdentityPolicy::class, RefreshTokenExpiration::class]) + ->middleware(BizppurioWebhookIpWhitelist::class) + ->name('webhook'); + Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () { // 리포트 수신 주소 조회 (관리자 설정 페이지 표시용) // diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php index c3ffd2e3..3798f8f0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php @@ -4,6 +4,8 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Installation; use App\Enums\ExtensionOwnerType; use App\Models\Menu; +use Illuminate\Support\Facades\Schema; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Plugin; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; @@ -138,4 +140,46 @@ class InstallationTest extends PluginTestCase '비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.' ); } + + /** + * Phase 4 발송 이력·연동 테이블이 마이그레이션으로 생성된다. + */ + public function test_phase4_테이블이_생성된다(): void + { + $this->assertTrue(Schema::hasTable('bizppurio_dispatches')); + $this->assertTrue(Schema::hasTable('bizppurio_notification_bindings')); + + $this->assertTrue(Schema::hasColumns('bizppurio_dispatches', [ + 'refkey', 'messagekey', 'channel', 'to_number', 'to_user_id', + 'status', 'result_code', 'reported_at', 'raw_payload', + ])); + } + + /** + * 마이그레이션 up→down→up 왕복 후에도 리포지토리 호출 1회전이 동작한다. + */ + public function test_마이그레이션_왕복_후_리포지토리가_동작한다(): void + { + $migrations = base_path('plugins/_bundled/sirsoft-message_bizppurio/database/migrations'); + + // down + $this->artisan('migrate:rollback', ['--path' => $migrations, '--realpath' => true])->run(); + $this->assertFalse(Schema::hasTable('bizppurio_dispatches')); + + // up + $this->artisan('migrate', ['--path' => $migrations, '--realpath' => true])->run(); + $this->assertTrue(Schema::hasTable('bizppurio_dispatches')); + + // 왕복 후 Repository 호출 1회전 (create → 조회) + $dispatch = BizppurioDispatch::create([ + 'refkey' => 'roundtrip', + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + $this->assertNotNull(BizppurioDispatch::query()->byRefkey('roundtrip')->first()); + $this->assertSame('roundtrip', $dispatch->refkey); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php new file mode 100644 index 00000000..00ad7efb --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php @@ -0,0 +1,249 @@ + $refkey, + 'channel' => $channel, + 'to_number' => '01011112222', + 'content' => '테스트 본문', + 'notification_type' => 'welcome', + 'status' => DispatchStatus::Sent->value, + 'source' => 'auto', + 'sent_at' => now(), + ]); + } + + /** + * 허용 IP 로 유효 리포트를 POST 하면 200 + 상태가 success 로 갱신. + * + * @scenario source=webhook,ip=allowed,result=success + * + * @effects dispatch_status_updated_to_success,reported_at_set + */ + public function test_allowed_ip_success_report_updates_status(): void + { + $dispatch = $this->seedDispatch('ref_success'); + + $response = $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, [ + 'REFKEY' => 'ref_success', + 'RESULT' => '4100', + 'MEDIA' => 'SMS', + ]); + + $response->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4100', $dispatch->result_code); + $this->assertSame('SMS', $dispatch->media); + $this->assertNotNull($dispatch->reported_at); + } + + /** + * 실패 결과코드는 status=failed 로 갱신. + * + * @scenario source=webhook,ip=allowed,result=failure + * + * @effects dispatch_status_updated_to_failed + */ + public function test_allowed_ip_failure_report_marks_failed(): void + { + $dispatch = $this->seedDispatch('ref_fail'); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_fail', 'RESULT' => '4400']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('4400', $dispatch->result_code); + } + + /** + * 차단 IP 는 403 + 이력 미변경. + * + * @scenario source=webhook,ip=blocked + * + * @effects webhook_blocked_ip_returns_403,dispatch_unchanged + */ + public function test_blocked_ip_returns_403(): void + { + $dispatch = $this->seedDispatch('ref_blocked'); + + $this->withServerVariables(['REMOTE_ADDR' => self::BLOCKED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_blocked', 'RESULT' => '4100']) + ->assertStatus(403); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Sent, $dispatch->status); + $this->assertNull($dispatch->reported_at); + } + + /** + * 미매칭 refkey(위조)는 200 흡수 + 아무 이력도 변경 안 됨. + * + * @scenario source=webhook,ip=allowed,refkey=unknown + * + * @effects webhook_unknown_refkey_absorbed_200 + */ + public function test_unknown_refkey_absorbed_with_200(): void + { + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'nope', 'RESULT' => '4100']) + ->assertStatus(200); + + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + + /** + * replay(이미 reported_at 있는 이력)는 멱등 — 상태를 다시 뒤집지 않음. + * + * @scenario source=webhook,ip=allowed,replay=true + * + * @effects webhook_replay_is_idempotent + */ + public function test_replay_report_is_idempotent(): void + { + $dispatch = $this->seedDispatch('ref_replay'); + $dispatch->update([ + 'status' => DispatchStatus::Success->value, + 'result_code' => '4100', + 'reported_at' => now()->subMinute(), + ]); + $firstReportedAt = $dispatch->fresh()->reported_at; + + // 두 번째(중복) 리포트가 실패 코드로 와도 성공 상태를 유지해야 함 + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_replay', 'RESULT' => '4400']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Success, $dispatch->status); + $this->assertSame('4100', $dispatch->result_code); + $this->assertEquals($firstReportedAt->timestamp, $dispatch->reported_at->timestamp); + } + + /** + * 잔액부족(9070 문자)은 failed + 잔액부족 자체알림 훅 1회 발화. + * + * @scenario source=webhook,ip=allowed,result=balance_low_sms + * + * @effects webhook_balance_low_marks_failed,balance_low_hook_fired + */ + public function test_balance_low_sms_marks_failed_and_fires_hook(): void + { + $dispatch = $this->seedDispatch('ref_balance', DispatchChannel::Sms->value); + + $fired = []; + HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) { + $fired[] = $args; + }); + + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_balance', 'RESULT' => '9070']) + ->assertStatus(200); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertCount(1, $fired, '잔액부족 훅은 1회만 발화해야 함'); + $this->assertSame(['9070', 'sms'], $fired[0]); + } + + /** + * 잔액부족 알림은 쿨다운 동안 채널별 1회만 발송된다 (D3 중복 방지). + * + * 서로 다른 발송건이 연속으로 9070(문자 잔액부족)을 받아도, 쿨다운 내에서는 + * 관리자 알림이 1회만 발화한다. 단, 발송 이력의 실패 기록은 두 건 모두 남는다. + * + * @scenario source=webhook,ip=allowed,result=balance_low_sms,repeat=true + * + * @effects balance_low_notification_deduplicated_within_cooldown,both_dispatches_marked_failed + */ + public function test_balance_low_notification_is_deduplicated_within_cooldown(): void + { + $first = $this->seedDispatch('ref_bal_1', DispatchChannel::Sms->value); + $second = $this->seedDispatch('ref_bal_2', DispatchChannel::Sms->value); + + $fired = []; + HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) { + $fired[] = $args; + }); + + // 두 발송건이 각각 9070(문자 잔액부족) 리포트를 받음 + foreach (['ref_bal_1', 'ref_bal_2'] as $refkey) { + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => $refkey, 'RESULT' => '9070']) + ->assertStatus(200); + } + + // 알림은 1회만 (쿨다운 중복 방지) + $this->assertCount(1, $fired, '쿨다운 동안 잔액부족 알림은 1회만 발화해야 함'); + + // 그러나 실패 이력은 두 건 모두 기록됨 + $this->assertSame(DispatchStatus::Failed, $first->fresh()->status); + $this->assertSame(DispatchStatus::Failed, $second->fresh()->status); + $this->assertSame('9070', $first->fresh()->result_code); + $this->assertSame('9070', $second->fresh()->result_code); + } + + /** + * webhook 은 코어 토큰/IDV 미들웨어 제외 — 비인증 실 POST 가 401 이 아닌 200. + * + * @scenario source=webhook,ip=allowed,auth=none + * + * @effects webhook_bypasses_auth_middleware_returns_200 + */ + public function test_webhook_bypasses_auth_middleware(): void + { + $this->seedDispatch('ref_noauth'); + + // Authorization 헤더 없이(비인증) POST — 미들웨어 제외로 200 이어야 함 + $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP]) + ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_noauth', 'RESULT' => '4100']) + ->assertStatus(200); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php index 45fba633..46dbe4d9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php @@ -3,13 +3,16 @@ namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Jobs; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus; use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; +use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioApiClient; use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase; /** - * SendMessageJob — 성공/일시오류 재시도/영구실패 즉시 판정 검증. + * SendMessageJob — 성공/일시오류 재시도/영구실패 판정 + 이력 갱신 검증(Phase 4). */ class SendMessageJobTest extends PluginTestCase { @@ -33,13 +36,39 @@ class SendMessageJobTest extends PluginTestCase return $mock; } - public function test_성공시_예외없이_종료(): void + /** + * 컨테이너에서 실제 리포지토리(Provider 바인딩)를 해석해 반환. + */ + private function dispatches(): BizppurioDispatchRepositoryInterface { - $job = new SendMessageJob($this->payload, 'ref1'); - $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk'])); + return app(BizppurioDispatchRepositoryInterface::class); + } - // 예외 미발생 = 정상 완료 - $this->assertTrue(true); + /** + * refkey 로 pending 이력을 1건 seed. + */ + private function seedPending(string $refkey): BizppurioDispatch + { + return BizppurioDispatch::create([ + 'refkey' => $refkey, + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'hi', + 'status' => DispatchStatus::Pending->value, + 'source' => 'auto', + ]); + } + + public function test_성공시_예외없이_종료하고_이력을_sent로_갱신한다(): void + { + $dispatch = $this->seedPending('ref1'); + + $job = new SendMessageJob($this->payload, 'ref1'); + $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk']), $this->dispatches()); + + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Sent, $dispatch->status); + $this->assertSame('mk', $dispatch->messagekey); } public function test_일시오류_결과코드는_예외를_던져_재시도한다(): void @@ -49,20 +78,24 @@ class SendMessageJobTest extends PluginTestCase $this->expectException(BizppurioApiException::class); try { - $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp'])); + $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp']), $this->dispatches()); } catch (BizppurioApiException $e) { $this->assertSame('5003', $e->getResultCode()); throw $e; } } - public function test_영구실패_결과코드는_예외없이_종료된다(): void + public function test_영구실패_결과코드는_예외없이_종료하고_이력을_failed로_갱신한다(): void { + $dispatch = $this->seedPending('ref1'); + // 3006(계정오류) = 영구실패 → 재시도 안 함(예외 없음) $job = new SendMessageJob($this->payload, 'ref1'); - $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error'])); + $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error']), $this->dispatches()); - $this->assertTrue(true); + $dispatch->refresh(); + $this->assertSame(DispatchStatus::Failed, $dispatch->status); + $this->assertSame('3006', $dispatch->result_code); } public function test_tries와_backoff_기본값(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php new file mode 100644 index 00000000..f0ef37f7 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php @@ -0,0 +1,90 @@ + 'r1', + 'channel' => 'lms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'pending', + 'source' => 'auto', + ]); + + $this->assertSame(DispatchChannel::Lms, $dispatch->channel); + $this->assertSame(DispatchStatus::Pending, $dispatch->status); + } + + /** + * 마스킹 접근자는 가운데 4자리를 가린다. + */ + public function test_masked_number(): void + { + $dispatch = new BizppurioDispatch(['to_number' => '01012345678']); + $this->assertSame('010-****-5678', $dispatch->masked_number); + } + + /** + * 하이픈 포함 번호도 정규화 후 마스킹한다. + */ + public function test_masked_number_with_hyphens(): void + { + $dispatch = new BizppurioDispatch(['to_number' => '010-1234-5678']); + $this->assertSame('010-****-5678', $dispatch->masked_number); + } + + /** + * to_user_id 로 회원 관계를 로드한다. + */ + public function test_user_relation(): void + { + $user = User::factory()->create(); + $dispatch = BizppurioDispatch::create([ + 'refkey' => 'r2', + 'channel' => 'sms', + 'to_number' => '01011112222', + 'to_user_id' => $user->id, + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + + $this->assertNotNull($dispatch->user); + $this->assertSame($user->id, $dispatch->user->id); + } + + /** + * byRefkey 스코프로 조회한다. + */ + public function test_by_refkey_scope(): void + { + BizppurioDispatch::create([ + 'refkey' => 'unique_ref', + 'channel' => 'sms', + 'to_number' => '01000000000', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + ]); + + $found = BizppurioDispatch::query()->byRefkey('unique_ref')->first(); + $this->assertNotNull($found); + $this->assertSame('unique_ref', $found->refkey); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php new file mode 100644 index 00000000..a9c8ea35 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php @@ -0,0 +1,75 @@ +repo = new BizppurioDispatchRepository; + } + + private function makeDispatch(array $overrides = []): BizppurioDispatch + { + return $this->repo->create(array_merge([ + 'refkey' => 'r'.uniqid(), + 'channel' => 'sms', + 'to_number' => '01011112222', + 'content' => 'x', + 'status' => 'sent', + 'source' => 'auto', + 'sent_at' => now(), + ], $overrides)); + } + + public function test_create_and_find_by_refkey(): void + { + $this->makeDispatch(['refkey' => 'known']); + $this->assertNotNull($this->repo->findByRefkey('known')); + $this->assertNull($this->repo->findByRefkey('missing')); + } + + public function test_update(): void + { + $dispatch = $this->makeDispatch(['refkey' => 'upd', 'status' => 'sent']); + $this->repo->update($dispatch, ['status' => 'success', 'result_code' => '4100']); + + $this->assertSame('success', $dispatch->fresh()->status->value); + $this->assertSame('4100', $dispatch->fresh()->result_code); + } + + public function test_paginate_filters_by_channel_and_status(): void + { + $this->makeDispatch(['channel' => 'sms', 'status' => 'success']); + $this->makeDispatch(['channel' => 'alimtalk', 'status' => 'failed']); + $this->makeDispatch(['channel' => 'sms', 'status' => 'failed']); + + $smsPage = $this->repo->paginate(['channel' => 'sms']); + $this->assertSame(2, $smsPage->total()); + + $failedPage = $this->repo->paginate(['status' => 'failed']); + $this->assertSame(2, $failedPage->total()); + } + + public function test_paginate_keyword_matches_number_or_refkey(): void + { + $this->makeDispatch(['refkey' => 'refABC', 'to_number' => '01099998888']); + $this->makeDispatch(['refkey' => 'other', 'to_number' => '01000000000']); + + $byRefkey = $this->repo->paginate(['keyword' => 'refABC']); + $this->assertSame(1, $byRefkey->total()); + + $byNumber = $this->repo->paginate(['keyword' => '9999']); + $this->assertSame(1, $byNumber->total()); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php new file mode 100644 index 00000000..88c0be33 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php @@ -0,0 +1,67 @@ +repo = new BizppurioNotificationBindingRepository; + } + + public function test_upsert_creates_then_updates(): void + { + $created = $this->repo->upsert('welcome', 'alimtalk', [ + 'template_code' => 'TW_1', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => true, + ]); + $this->assertSame('TW_1', $created->template_code); + + // 같은 (type, channel) 재저장 → 갱신(중복 생성 아님) + $updated = $this->repo->upsert('welcome', 'alimtalk', [ + 'template_code' => 'TW_2', + 'template_name' => '가입환영v2', + 'fallback_sms_enabled' => false, + ]); + + $this->assertSame($created->id, $updated->id); + $this->assertSame('TW_2', $updated->template_code); + $this->assertDatabaseCount('bizppurio_notification_bindings', 1); + } + + public function test_find_active_only_returns_active(): void + { + $this->repo->upsert('welcome', 'alimtalk', [ + 'template_code' => 'TW_1', + 'template_name' => 'n', + 'is_active' => true, + ]); + + $this->assertNotNull($this->repo->findActive('welcome')); + + $this->repo->upsert('welcome', 'alimtalk', ['is_active' => false, 'template_code' => 'TW_1', 'template_name' => 'n']); + $this->assertNull($this->repo->findActive('welcome')); + } + + public function test_delete(): void + { + $this->repo->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_9', + 'template_name' => '주문완료', + ]); + + $this->repo->delete('order_confirmed', 'alimtalk'); + $this->assertNull($this->repo->findActive('order_confirmed')); + $this->assertDatabaseCount('bizppurio_notification_bindings', 0); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php new file mode 100644 index 00000000..6da7f577 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php @@ -0,0 +1,84 @@ +resolver = new ResultCodeResolver; + } + + /** + * 성공 코드(발송 1000 / SMS 4100 / LMS 6600 / 알림톡 7000 / 카카오 200)는 Success. + */ + public function test_success_codes_categorized_as_success(): void + { + foreach (['1000', '4100', '6600', '7000', '200'] as $code) { + $this->assertSame(ResultCategory::Success, $this->resolver->categorize($code), "코드 {$code}"); + $this->assertTrue($this->resolver->isSuccess($code), "코드 {$code}"); + } + } + + /** + * 잔액부족(9070 문자 / 7436 알림톡)은 BalanceLow + isBalanceLow true. + */ + public function test_balance_low_codes(): void + { + foreach (['9070', '7436'] as $code) { + $this->assertSame(ResultCategory::BalanceLow, $this->resolver->categorize($code), "코드 {$code}"); + $this->assertTrue($this->resolver->isBalanceLow($code), "코드 {$code}"); + $this->assertFalse($this->resolver->isSuccess($code), "코드 {$code}"); + } + } + + /** + * 일시오류 코드는 Retry. + */ + public function test_retryable_codes_categorized_as_retry(): void + { + foreach (['5003', '5004', '5005', '9000', '3011', '3013'] as $code) { + $this->assertSame(ResultCategory::Retry, $this->resolver->categorize($code), "코드 {$code}"); + } + } + + /** + * 위 분류에 없는 코드는 영구 실패. + */ + public function test_unknown_and_failure_codes_are_permanent_failure(): void + { + foreach (['4400', '6606', '7204', '2000', '9999'] as $code) { + $this->assertSame(ResultCategory::PermanentFailure, $this->resolver->categorize($code), "코드 {$code}"); + } + } + + /** + * reason 은 lang 정의 코드는 사유를, 미정의 코드는 null 을 반환. + */ + public function test_reason_resolves_defined_codes_and_null_for_unknown(): void + { + $this->assertSame('음영 지역', $this->resolver->reason('4400')); + $this->assertNull($this->resolver->reason('9999')); + } + + /** + * label 은 정의 코드는 "사유 (코드)", 미정의 코드는 코드만 반환. + */ + public function test_label_formats_reason_with_code(): void + { + $this->assertSame('음영 지역 (4400)', $this->resolver->label('4400')); + $this->assertSame('9999', $this->resolver->label('9999')); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index a89fcb4f..0028ede4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -10,7 +10,9 @@ use App\Services\NotificationTemplateService; use Illuminate\Notifications\Notification; use Illuminate\Support\Facades\Bus; use Mockery; +use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch; use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob; +use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver; @@ -56,6 +58,7 @@ class SmsChannelDriverTest extends PluginTestCase $templateService, new SmsTypeResolver, $builder ?? $this->spyBuilder(), + new BizppurioDispatchRepository, ); } @@ -127,6 +130,45 @@ class SmsChannelDriverTest extends PluginTestCase $this->assertSame('01099990000', $builder->calls[0]['to']); } + public function test_회원_발송_시_pending_이력을_생성하고_회원id를_기록한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']); + + $this->makeDriver($this->fakeTemplate())->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 1); + $dispatch = BizppurioDispatch::first(); + $this->assertSame('pending', $dispatch->status->value); + $this->assertSame('01012345678', $dispatch->to_number); + $this->assertSame($member->id, $dispatch->to_user_id, '회원 발송은 to_user_id 를 채워야 한다.'); + $this->assertSame('order_confirmed', $dispatch->notification_type); + } + + public function test_비회원_발송_이력은_회원id가_null이다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + + $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification($data)); + + $dispatch = BizppurioDispatch::first(); + $this->assertNotNull($dispatch); + $this->assertNull($dispatch->to_user_id, '비회원 발송은 to_user_id 가 null 이어야 한다.'); + } + + public function test_발송하지_않으면_이력도_생성하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // 템플릿 없음 → skip → 이력도 없어야 함 + $this->makeDriver(null)->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + public function test_전화번호가_없으면_발송하지_않는다(): void { Bus::fake(); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml new file mode 100644 index 00000000..6f22db53 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml @@ -0,0 +1,73 @@ +# audit:allow test-scenario-coverage reason: | +# 발송 결과 기록·webhook 수신·결과코드 해석의 axes cross product 는 PHPUnit +# (BizppurioWebhookTest·ResultCodeResolverTest·SmsChannelDriverTest·SendMessageJobTest· +# BizppurioDispatchTest·리포지토리·Installation)로 실제 HTTP/DB 왕복을 관찰한다. +# 발송 이력의 "표시 화면"은 이번 단계에 두지 않는다 — 코어 알림 발송 이력 화면에 비즈뿌리오 +# 결과(도달·실패사유)를 얹는 방식(A-2, 후속 Phase)으로 이관하기로 결정했기 때문이다. +# 따라서 이 매니페스트는 결과 저장·수신·해석까지만 커버하며, 표시 UI axis/effects 는 +# A-2 착수 시 그 화면(코어 이력 + 주입)에 대한 매니페스트로 추가한다. + +feature: 비즈뿌리오 발송 결과 기록 + webhook 수신 + 결과코드 (Phase 4) + +description: | + 발송 결과를 bizppurio_dispatches 에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로 상태를 + 갱신하며, 결과코드를 사유+분류로 해석한다. (표시 화면은 A-2 후속에서 코어 이력에 통합) + + 핵심 동작: + - 발송 시 SmsChannelDriver 가 pending 이력 생성(회원=to_user_id, 비회원=null) + - SendMessageJob 이 발송 응답으로 sent(+messagekey) 또는 failed(+result_code) 갱신 + - webhook: IP 화이트리스트(115.71.53.78/79/94/95, 환경 우회 없음) → refkey 매칭 → + replay 멱등(reported_at) → RESULT 분류 → 상태전이 + media/fallback/raw_payload/reported_at + - 잔액부족(9070 문자 / 7436 알림톡): failed 뒤집기 + 관리자 자체알림(쿨다운 내 1회) + - webhook 은 코어 토큰/IDV 미들웨어 제외(라우트 withoutMiddleware) — 비인증 실 POST 200 + +axes: + channel: [sms, lms, alimtalk] + result_category: [success, failure, retry, balance_low] + recipient_kind: [member, guest] + webhook_ip: [allowed, blocked] + +exclusions: + - { webhook_ip: blocked, result_category: success, reason: "차단 IP 는 결과코드와 무관하게 403 — 분류 조합 무의미" } + - { channel: alimtalk, result_category: retry, reason: "알림톡 발송 드라이버는 Phase 6 — 이번 범위는 채널 등록/이력 스키마까지" } + +effects: + # 발송 시 이력 생성 (백엔드) + - sms_send_creates_pending_dispatch_with_member_user_id + - guest_send_creates_pending_dispatch_with_null_user_id + - skip_send_creates_no_dispatch + # 발송 응답 → 이력 갱신 (백엔드) + - send_success_marks_sent_and_stores_messagekey + - send_permanent_failure_marks_failed_with_result_code + - send_retryable_throws_for_queue_retry + # webhook 수신 (백엔드) + - allowed_ip_success_report_updates_status_to_success + - allowed_ip_failure_report_marks_failed + - blocked_ip_returns_403_and_leaves_dispatch_unchanged + - unknown_refkey_absorbed_with_200 + - replay_report_is_idempotent + - balance_low_marks_failed_and_fires_admin_notification_once + - balance_low_notification_deduplicated_within_cooldown_but_all_dispatches_marked_failed + - webhook_bypasses_auth_middleware_returns_200 + # 결과코드 해석 (백엔드) + - success_codes_categorized_as_success + - balance_low_codes_9070_7436_categorized_as_balance_low + - retryable_codes_categorized_as_retry + - unknown_codes_categorized_as_permanent_failure + - label_formats_reason_with_code + # DB (마이그레이션) + - phase4_tables_created_with_expected_columns + - migration_roundtrip_up_down_up_preserves_repository_ops + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php + +audit: + status: codified