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