feat(message_bizppurio): 발송 결과 기록 + webhook 수신 + 결과코드
문자·알림톡 발송 결과를 DB에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로 도달 여부를 갱신한다. 결과코드를 사유+분류로 해석하고, 잔액 부족 시 관리자에게 쿨다운(기본 1시간) 내 1회만 알린다. 발송 결과를 확인하는 화면은 플러그인 독립 페이지 대신 코어 알림 이력에 얹는 방식(후속)으로 이관하기로 하여, 이번에 만든 독립 이력 화면과 화면 전용 백엔드는 제거했다. webhook 결과 저장·수신·해석 뼈대는 유지한다.
This commit is contained in:
@@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
|
||||
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
|
||||
|
||||
<!-- @generated:start:api-readme-extensions -->
|
||||
- **확장 수**: 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 |
|
||||
|
||||
|
||||
@@ -16,3 +16,5 @@
|
||||
- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다.
|
||||
- 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다.
|
||||
- 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.)
|
||||
- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.)
|
||||
- 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송 이력 테이블.
|
||||
*
|
||||
* 문자(SMS/LMS)·알림톡 1건 발송마다 1행. 발송 시 pending 으로 생성되고,
|
||||
* 비즈뿌리오 webhook(URL PUSH) 리포트 수신 시 refkey 로 매칭해 상태를 갱신한다.
|
||||
* 발송 이력 화면(계획서 §6-5)의 데이터소스.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('bizppurio_dispatches');
|
||||
|
||||
Schema::create('bizppurio_dispatches', function (Blueprint $table) {
|
||||
$table->bigIncrements('id')->comment('발송 이력 PK');
|
||||
$table->string('refkey', 32)->comment('우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)');
|
||||
$table->string('messagekey', 64)->nullable()->comment('비즈뿌리오 부여 키 (발송 응답 messagekey)');
|
||||
$table->string('channel', 20)->comment('발송 채널: sms / lms / alimtalk (DispatchChannel enum)');
|
||||
$table->string('media', 10)->nullable()->comment('webhook MEDIA — 실제 발송 유형: SMS / LMS / KAT(알림톡)');
|
||||
$table->string('to_number', 20)->comment('수신 전화번호 (숫자만)');
|
||||
$table->string('to_name', 100)->nullable()->comment('수신자명 (발송 시점 스냅샷)');
|
||||
$table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null')
|
||||
->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete();
|
||||
$table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)');
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결(설정) 테이블.
|
||||
*
|
||||
* "어느 알림에 어느 알림톡 템플릿을 쓸지 + 대체발송 여부"를 저장한다.
|
||||
* 알림 설정 알림톡 탭 편집 모달(계획서 §6-2)이 우리 API 로 저장한다.
|
||||
* 변수 매핑 컬럼은 없다 — 변수명 동일 규칙으로 발송 시 자동 치환한다.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('bizppurio_notification_bindings');
|
||||
|
||||
Schema::create('bizppurio_notification_bindings', function (Blueprint $table) {
|
||||
$table->bigIncrements('id')->comment('연결 설정 PK');
|
||||
$table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)');
|
||||
$table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)');
|
||||
$table->string('template_code', 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');
|
||||
}
|
||||
};
|
||||
@@ -4,11 +4,12 @@
|
||||
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 1 · **엔드포인트 수**: 1
|
||||
- **문서 수**: 2 · **엔드포인트 수**: 2
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
| [report.md](report.md) | `report` | 1 |
|
||||
| [webhook.md](webhook.md) | `webhook` | 1 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
|
||||
@@ -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…` | <!-- TODO: 설명 --> |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
@@ -77,3 +77,4 @@ URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합
|
||||
|
||||
※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# Webhook API 레퍼런스
|
||||
|
||||
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Webhook 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### POST /api/plugins/sirsoft-message_bizppurio/webhook
|
||||
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.webhook -->
|
||||
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.webhook`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController@handle`
|
||||
- **인증/권한**: 공개 (인증 불필요)
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| DEVICE | body | string | 아니오 | max 20 | <!-- TODO: 용도 --> |
|
||||
| CMSGID | body | string | 아니오 | max 64 | <!-- TODO: 용도 --> |
|
||||
| MSGID | body | string | 아니오 | max 64 | <!-- TODO: 용도 --> |
|
||||
| PHONE | body | string | 아니오 | max 20 | <!-- TODO: 용도 --> |
|
||||
| MEDIA | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
|
||||
| RESULT | body | string | 예 | max 10 | <!-- TODO: 용도 --> |
|
||||
| REFKEY | body | string | 예 | max 32 | <!-- TODO: 용도 --> |
|
||||
| TELRES | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
|
||||
| KAORES | body | string | 아니오 | max 10 | <!-- TODO: 용도 --> |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-message_bizppurio/webhook HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"DEVICE": "예시값",
|
||||
"CMSGID": "예시값",
|
||||
"MSGID": "예시값",
|
||||
"PHONE": "010-1234-5678",
|
||||
"MEDIA": "예시값",
|
||||
"RESULT": "예시값",
|
||||
"REFKEY": "예시값",
|
||||
"TELRES": "예시값",
|
||||
"KAORES": "예시값"
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 필드는 사람이 작성하세요. -->
|
||||
|
||||
**응답 예시**
|
||||
|
||||
<!-- 실측 제외: http-403 — 응답 예시는 사람이 작성하세요. -->
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
비즈뿌리오가 문자·알림톡 발송 결과를 URL PUSH 로 통보하는 리포트 수신 엔드포인트다. 운영자가 이 주소를 비즈뿌리오에 등록하면(환경설정 화면의 리포트 수신 주소), 발송 후 결과가 이 엔드포인트로 전송된다.
|
||||
|
||||
- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403.
|
||||
- **처리**: `REFKEY` 로 발송 이력을 조회한다. 없으면(위조/미매칭) 200 으로 흡수한다. 이미 리포트가 반영된 이력(`reported_at` 존재)이면 replay 로 판정해 멱등 처리한다. 그 외에는 `RESULT` 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 `media`·`fallback_status`·`raw_payload`·`reported_at` 을 기록한다.
|
||||
- **잔액부족**: `RESULT` 가 9070(문자)/7436(알림톡)이면 이력을 실패로 뒤집고 관리자에게 자체 알림을 1회 발송한다.
|
||||
- 응답은 항상 200 이다(비즈뿌리오가 실패 응답을 재전송하지 않도록). replay 멱등이 중복 처리를 막는다.
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Bizppurio result code → reason (English)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Source: Bizppurio API manual "9. Status_Code" table
|
||||
| As of: 2026-07-13 (plan appendix C-3, phase 1 scope)
|
||||
| Scope: common(2000~9070) + SMS(4100~4443) + LMS(6600~6641) + Alimtalk(7000~7523).
|
||||
| RCS(8000s)/NaverTalkTalk(part of 5000s) are out of scope (follow-up D15).
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
// ── Common / send response ───────────────────────────
|
||||
'1000' => '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',
|
||||
];
|
||||
@@ -59,6 +59,11 @@ return [
|
||||
'sender_number_attribute' => '발신번호',
|
||||
],
|
||||
|
||||
// webhook(URL PUSH) 리포트 수신
|
||||
'webhook' => [
|
||||
'received' => '리포트를 수신했습니다.',
|
||||
],
|
||||
|
||||
// 발송 엔진 오류 (API 클라이언트·토큰·발송 Job)
|
||||
'error' => [
|
||||
'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.',
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 비즈뿌리오 결과 코드 → 사유 (한국어)
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| 출처: 비즈뿌리오 API 매뉴얼 "9. Status_Code" 표
|
||||
| 기준일: 2026-07-13 (계획서 부록 C-3 1차 범위)
|
||||
| 범위: 공통(2000~9070) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523)
|
||||
| RCS(8000대)·네이버톡톡(5000대 일부)은 범위 밖(후속 D15).
|
||||
|
|
||||
| 매뉴얼이 삭제되어도 이 파일이 원본 스펙을 보존한다. 새 코드 추가 시 분류는
|
||||
| ResultCodeResolver 의 상수 배열(SUCCESS/RETRYABLE/BALANCE_LOW)과 함께 갱신한다.
|
||||
|
|
||||
*/
|
||||
|
||||
return [
|
||||
// ── 공통 / 발송 응답 ──────────────────────────────────
|
||||
'1000' => '성공',
|
||||
'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' => '기타 오류',
|
||||
];
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Concerns;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
|
||||
/**
|
||||
* webhook replay 방어 — 동일 발송 이력에 리포트가 중복 도착하면 멱등 처리한다.
|
||||
*
|
||||
* 비즈뿌리오 URL PUSH 는 동일 리포트를 재전송할 수 있다. 발송 이력의 `reported_at`
|
||||
* 이 이미 채워져 있으면 리포트를 이미 반영한 것이므로, 상태를 다시 뒤집거나 잔액부족
|
||||
* 자체 알림을 재발송하지 않도록 조기 리턴한다(계획서 D13).
|
||||
*/
|
||||
trait PreventsReplayWebhook
|
||||
{
|
||||
/**
|
||||
* 이 이력이 이미 리포트를 반영했는지(=replay) 확인합니다.
|
||||
*
|
||||
* @param BizppurioDispatch $dispatch refkey 로 매칭된 발송 이력
|
||||
* @return bool true 면 중복 리포트 — 멱등 응답으로 처리해야 함
|
||||
*/
|
||||
protected function wasAlreadyReported(BizppurioDispatch $dispatch): bool
|
||||
{
|
||||
return $dispatch->reported_at !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* replay 감지를 통일된 형식으로 로깅합니다 (운영 모니터링용).
|
||||
*
|
||||
* @param string $refkey 우리 부여 키
|
||||
* @param string|null $resultCode 리포트 결과 코드
|
||||
*/
|
||||
protected function logReplayDetected(string $refkey, ?string $resultCode): void
|
||||
{
|
||||
Log::info('비즈뿌리오 webhook: replay 감지 — 이미 리포트 반영됨, 멱등 응답', [
|
||||
'refkey' => $refkey,
|
||||
'result_code' => $resultCode,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Controllers;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioWebhookRequest;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 webhook(URL PUSH) 리포트 수신 컨트롤러.
|
||||
*
|
||||
* 인증은 라우트의 IP 화이트리스트 미들웨어가 담당한다. 검증된 리포트를
|
||||
* WebhookReportService 에 위임해 상태전이·replay 멱등·잔액부족 알림을 처리한다.
|
||||
*
|
||||
* 항상 200 으로 응답한다 — 비즈뿌리오가 실패 응답을 재전송(=중복 리포트)하지 않도록
|
||||
* 하기 위함이며, 위조/미매칭도 성공 수신으로 흡수한다(replay 멱등이 재처리를 막음).
|
||||
*/
|
||||
class BizppurioWebhookController
|
||||
{
|
||||
/**
|
||||
* @param WebhookReportService $reports 리포트 처리 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly WebhookReportService $reports,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* webhook 리포트를 수신해 발송 이력 상태를 갱신합니다.
|
||||
*
|
||||
* @param BizppurioWebhookRequest $request 검증된 리포트
|
||||
* @return JsonResponse 항상 200
|
||||
*/
|
||||
public function handle(BizppurioWebhookRequest $request): JsonResponse
|
||||
{
|
||||
$this->reports->apply([
|
||||
'REFKEY' => $request->input('REFKEY'),
|
||||
'RESULT' => $request->input('RESULT'),
|
||||
'MEDIA' => $request->input('MEDIA'),
|
||||
'TELRES' => $request->input('TELRES'),
|
||||
'KAORES' => $request->input('KAORES'),
|
||||
'raw' => $request->all(),
|
||||
]);
|
||||
|
||||
return ResponseHelper::success('sirsoft-message_bizppurio::messages.webhook.received');
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 webhook(URL PUSH) IP 화이트리스트 미들웨어.
|
||||
*
|
||||
* 비즈뿌리오 공식 발송 IP 만 허용한다. webhook 은 토큰/IDV 미들웨어를 제외(라우트
|
||||
* 레벨 withoutMiddleware)하고 인증을 이 IP 화이트리스트로 대체하므로, 이 게이트가
|
||||
* 위·변조 리포트에 대한 1차 방어선이다.
|
||||
*
|
||||
* 환경별 우회를 두지 않는다(계획서 결정 A). testing/local 에서도 동일하게 IP 를
|
||||
* 검증해, "차단 IP → 403" 이 테스트로 실증되고 프로덕션과 동일 경로를 검증한다.
|
||||
*/
|
||||
class BizppurioWebhookIpWhitelist
|
||||
{
|
||||
/** 비즈뿌리오 공식 URL PUSH 발송 IP (매뉴얼 부록 C-4) */
|
||||
private const ALLOWED_IPS = [
|
||||
'115.71.53.78',
|
||||
'115.71.53.79',
|
||||
'115.71.53.94',
|
||||
'115.71.53.95',
|
||||
];
|
||||
|
||||
/**
|
||||
* 요청 IP 가 화이트리스트에 있는지 확인하고 통과 여부를 결정합니다.
|
||||
*
|
||||
* @param Request $request 들어온 HTTP 요청
|
||||
* @param Closure $next 다음 미들웨어
|
||||
* @return Response 다음 미들웨어 응답 또는 403
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! in_array($request->ip(), self::ALLOWED_IPS, true)) {
|
||||
abort(403, 'Forbidden');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 webhook(URL PUSH) 리포트 검증 Request.
|
||||
*
|
||||
* 필수 필드(부록 C-4): DEVICE(메시지유형) · CMSGID(메시지키) · MSGID(비즈뿌리오키) ·
|
||||
* PHONE(수신번호) · MEDIA(실발송유형) · RESULT(결과코드) · REFKEY(우리 부여 키).
|
||||
* 선택 필드: TO NAME · WAPINFO · TELRES/TELTIME(대체발송 결과) · KAORES/KAOTIME 등.
|
||||
*
|
||||
* 인증/권한은 라우트의 IP 화이트리스트 미들웨어가 담당하므로 authorize() 는 true 고정.
|
||||
*/
|
||||
class BizppurioWebhookRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 검사는 IP 화이트리스트 미들웨어가 담당 — 항상 통과.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* webhook 리포트 검증 규칙.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'DEVICE' => ['nullable', 'string', 'max:20'],
|
||||
'CMSGID' => ['nullable', 'string', 'max:64'],
|
||||
'MSGID' => ['nullable', 'string', 'max:64'],
|
||||
'PHONE' => ['nullable', 'string', 'max:20'],
|
||||
'MEDIA' => ['nullable', 'string', 'max:10'],
|
||||
'RESULT' => ['required', 'string', 'max:10'],
|
||||
'REFKEY' => ['required', 'string', 'max:32'],
|
||||
'TELRES' => ['nullable', 'string', 'max:10'],
|
||||
'KAORES' => ['nullable', 'string', 'max:10'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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<string, mixed> $data 갱신 데이터
|
||||
*/
|
||||
private function updateDispatch(BizppurioDispatchRepositoryInterface $dispatches, array $data): void
|
||||
{
|
||||
$dispatch = $dispatches->findByRefkey($this->refkey);
|
||||
if ($dispatch !== null) {
|
||||
$dispatches->update($dispatch, $data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 재시도 사이 대기 시간(초)을 반환합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송 이력 모델.
|
||||
*
|
||||
* 문자·알림톡 1건 발송마다 1행. 발송 시 pending 으로 생성되고 webhook 리포트로 상태가
|
||||
* 갱신된다. 발송 이력 화면(계획서 §6-5)의 데이터소스.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $refkey
|
||||
* @property string|null $messagekey
|
||||
* @property string $channel
|
||||
* @property string|null $media
|
||||
* @property string $to_number
|
||||
* @property string|null $to_name
|
||||
* @property int|null $to_user_id
|
||||
* @property string $content
|
||||
* @property string|null $notification_type
|
||||
* @property string $status
|
||||
* @property string|null $result_code
|
||||
* @property string|null $result_message
|
||||
* @property string|null $fallback_status
|
||||
* @property string $source
|
||||
* @property \Carbon\Carbon|null $sent_at
|
||||
* @property \Carbon\Carbon|null $reported_at
|
||||
* @property array|null $raw_payload
|
||||
* @property \Carbon\Carbon|null $created_at
|
||||
* @property \Carbon\Carbon|null $updated_at
|
||||
* @property-read User|null $user
|
||||
*/
|
||||
class BizppurioDispatch extends Model
|
||||
{
|
||||
/**
|
||||
* 테이블명
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'bizppurio_dispatches';
|
||||
|
||||
/**
|
||||
* 대량 할당 허용 필드
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'refkey',
|
||||
'messagekey',
|
||||
'channel',
|
||||
'media',
|
||||
'to_number',
|
||||
'to_name',
|
||||
'to_user_id',
|
||||
'content',
|
||||
'notification_type',
|
||||
'status',
|
||||
'result_code',
|
||||
'result_message',
|
||||
'fallback_status',
|
||||
'source',
|
||||
'sent_at',
|
||||
'reported_at',
|
||||
'raw_payload',
|
||||
];
|
||||
|
||||
/**
|
||||
* 속성 캐스팅 정의
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'channel' => DispatchChannel::class,
|
||||
'status' => DispatchStatus::class,
|
||||
'source' => DispatchSource::class,
|
||||
'to_user_id' => 'integer',
|
||||
'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);
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결(설정) 모델.
|
||||
*
|
||||
* "어느 알림에 어느 알림톡 템플릿을 쓸지 + 대체발송 여부"를 저장한다.
|
||||
* 알림 설정 알림톡 탭 편집 모달(계획서 §6-2)이 우리 API 로 저장하고,
|
||||
* 알림톡 채널 드라이버(Phase 6)가 이 연결을 조회해 발송 대상 템플릿을 결정한다.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $notification_type
|
||||
* @property string $channel
|
||||
* @property string $template_code
|
||||
* @property string $template_name
|
||||
* @property bool $fallback_sms_enabled
|
||||
* @property bool $is_active
|
||||
* @property \Carbon\Carbon|null $created_at
|
||||
* @property \Carbon\Carbon|null $updated_at
|
||||
*/
|
||||
class BizppurioNotificationBinding extends Model
|
||||
{
|
||||
/**
|
||||
* 테이블명
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $table = 'bizppurio_notification_bindings';
|
||||
|
||||
/**
|
||||
* 대량 할당 허용 필드
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'notification_type',
|
||||
'channel',
|
||||
'template_code',
|
||||
'template_name',
|
||||
'fallback_sms_enabled',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
/**
|
||||
* 속성 캐스팅 정의
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'channel' => DispatchChannel::class,
|
||||
'fallback_sms_enabled' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 연동만 조회하는 스코프.
|
||||
*
|
||||
* @param Builder $query
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->where('is_active', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 유형으로 조회하는 스코프 (발송 시 template_code 해석).
|
||||
*
|
||||
* @param Builder $query
|
||||
* @param string $notificationType 코어 notification_definitions.type
|
||||
* @return Builder
|
||||
*/
|
||||
public function scopeByNotificationType(Builder $query, string $notificationType): Builder
|
||||
{
|
||||
return $query->where('notification_type', $notificationType);
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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<class-string, class-string>
|
||||
*/
|
||||
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<int, class-string>
|
||||
*/
|
||||
protected array $cacheServices = [
|
||||
BizppurioTokenService::class,
|
||||
WebhookReportService::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Repositories;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송 이력 Repository 구현체.
|
||||
*/
|
||||
class BizppurioDispatchRepository implements BizppurioDispatchRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 발송 이력 1건을 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $data 이력 데이터
|
||||
* @return BizppurioDispatch 생성된 이력
|
||||
*/
|
||||
public function create(array $data): BizppurioDispatch
|
||||
{
|
||||
return BizppurioDispatch::create($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* refkey 로 발송 이력을 조회합니다.
|
||||
*
|
||||
* @param string $refkey 우리 부여 키
|
||||
* @return BizppurioDispatch|null 매칭된 이력 또는 null
|
||||
*/
|
||||
public function findByRefkey(string $refkey): ?BizppurioDispatch
|
||||
{
|
||||
return BizppurioDispatch::query()->byRefkey($refkey)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력의 속성을 갱신합니다.
|
||||
*
|
||||
* @param BizppurioDispatch $dispatch 대상 이력
|
||||
* @param array<string, mixed> $data 갱신 데이터
|
||||
* @return BizppurioDispatch 갱신된 이력
|
||||
*/
|
||||
public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch
|
||||
{
|
||||
$dispatch->fill($data)->save();
|
||||
|
||||
return $dispatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다.
|
||||
*
|
||||
* @param array<string, mixed> $filters channel / status / date_from / date_to / keyword
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @return LengthAwarePaginator<BizppurioDispatch>
|
||||
*/
|
||||
public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator
|
||||
{
|
||||
$query = BizppurioDispatch::query();
|
||||
|
||||
if (! empty($filters['channel'])) {
|
||||
$query->where('channel', $filters['channel']);
|
||||
}
|
||||
|
||||
if (! empty($filters['status'])) {
|
||||
$query->where('status', $filters['status']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_from'])) {
|
||||
$query->where('sent_at', '>=', $filters['date_from']);
|
||||
}
|
||||
|
||||
if (! empty($filters['date_to'])) {
|
||||
$query->where('sent_at', '<=', $filters['date_to']);
|
||||
}
|
||||
|
||||
if (! empty($filters['keyword'])) {
|
||||
$keyword = $filters['keyword'];
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('to_number', 'like', "%{$keyword}%")
|
||||
->orWhere('to_name', 'like', "%{$keyword}%")
|
||||
->orWhere('refkey', 'like', "%{$keyword}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $query->orderByDesc('created_at')->paginate($perPage);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Repositories;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결 Repository 구현체.
|
||||
*/
|
||||
class BizppurioNotificationBindingRepository implements BizppurioNotificationBindingRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 알림 유형+채널로 활성 연동을 조회합니다.
|
||||
*
|
||||
* @param string $notificationType 코어 notification_definitions.type
|
||||
* @param string $channel 채널
|
||||
* @return BizppurioNotificationBinding|null 매칭 연동 또는 null
|
||||
*/
|
||||
public function findActive(string $notificationType, string $channel = 'alimtalk'): ?BizppurioNotificationBinding
|
||||
{
|
||||
return BizppurioNotificationBinding::query()
|
||||
->active()
|
||||
->byNotificationType($notificationType)
|
||||
->where('channel', $channel)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널의 모든 연동을 조회합니다.
|
||||
*
|
||||
* @param string $channel 채널
|
||||
* @return Collection<int, BizppurioNotificationBinding>
|
||||
*/
|
||||
public function allByChannel(string $channel = 'alimtalk'): Collection
|
||||
{
|
||||
return BizppurioNotificationBinding::query()
|
||||
->where('channel', $channel)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 유형+채널 연동을 생성하거나 갱신합니다.
|
||||
*
|
||||
* @param string $notificationType 코어 notification_definitions.type
|
||||
* @param string $channel 채널
|
||||
* @param array<string, mixed> $data 갱신 데이터
|
||||
* @return BizppurioNotificationBinding 저장된 연동
|
||||
*/
|
||||
public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding
|
||||
{
|
||||
$binding = BizppurioNotificationBinding::firstOrNew([
|
||||
'notification_type' => $notificationType,
|
||||
'channel' => $channel,
|
||||
]);
|
||||
|
||||
$binding->fill($data)->save();
|
||||
|
||||
return $binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 유형+채널 연동을 삭제합니다.
|
||||
*
|
||||
* @param string $notificationType 코어 notification_definitions.type
|
||||
* @param string $channel 채널
|
||||
* @return void
|
||||
*/
|
||||
public function delete(string $notificationType, string $channel = 'alimtalk'): void
|
||||
{
|
||||
BizppurioNotificationBinding::query()
|
||||
->byNotificationType($notificationType)
|
||||
->where('channel', $channel)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송 이력 Repository 계약.
|
||||
*
|
||||
* 발송 시 pending 이력을 생성하고, webhook 리포트로 refkey 매칭 후 상태를 갱신한다.
|
||||
* 발송 이력 화면(계획서 §6-5)의 목록 조회를 담당한다.
|
||||
*/
|
||||
interface BizppurioDispatchRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 발송 이력 1건을 생성합니다 (발송 시점, 통상 pending).
|
||||
*
|
||||
* @param array<string, mixed> $data 이력 데이터
|
||||
* @return BizppurioDispatch 생성된 이력
|
||||
*/
|
||||
public function create(array $data): BizppurioDispatch;
|
||||
|
||||
/**
|
||||
* refkey 로 발송 이력을 조회합니다 (webhook 매칭).
|
||||
*
|
||||
* @param string $refkey 우리 부여 키
|
||||
* @return BizppurioDispatch|null 매칭된 이력 또는 null(위조)
|
||||
*/
|
||||
public function findByRefkey(string $refkey): ?BizppurioDispatch;
|
||||
|
||||
/**
|
||||
* 발송 이력의 속성을 갱신합니다.
|
||||
*
|
||||
* @param BizppurioDispatch $dispatch 대상 이력
|
||||
* @param array<string, mixed> $data 갱신 데이터
|
||||
* @return BizppurioDispatch 갱신된 이력
|
||||
*/
|
||||
public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch;
|
||||
|
||||
/**
|
||||
* 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다 (이력 화면).
|
||||
*
|
||||
* @param array<string, mixed> $filters channel / status / date_from / date_to / keyword
|
||||
* @param int $perPage 페이지당 건수
|
||||
* @return LengthAwarePaginator<BizppurioDispatch>
|
||||
*/
|
||||
public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator;
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 이벤트↔알림톡 템플릿 연결 Repository 계약.
|
||||
*
|
||||
* 알림톡 탭 연동 CRUD(계획서 §6-2, Phase 6)와 알림톡 발송 시 template_code 해석을
|
||||
* 담당한다. Phase 4 는 계약과 기본 조회/저장을 제공한다.
|
||||
*/
|
||||
interface BizppurioNotificationBindingRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 알림 유형+채널로 활성 연동을 조회합니다 (발송 시 template_code 해석).
|
||||
*
|
||||
* @param string $notificationType 코어 notification_definitions.type
|
||||
* @param string $channel 채널 (기본 alimtalk)
|
||||
* @return BizppurioNotificationBinding|null 매칭 연동 또는 null(미연결)
|
||||
*/
|
||||
public function findActive(string $notificationType, string $channel = 'alimtalk'): ?BizppurioNotificationBinding;
|
||||
|
||||
/**
|
||||
* 채널의 모든 연동을 조회합니다 (알림톡 탭 목록).
|
||||
*
|
||||
* @param string $channel 채널 (기본 alimtalk)
|
||||
* @return Collection<int, BizppurioNotificationBinding>
|
||||
*/
|
||||
public function allByChannel(string $channel = 'alimtalk'): Collection;
|
||||
|
||||
/**
|
||||
* 알림 유형+채널 연동을 생성하거나 갱신합니다 (탭 편집 모달 저장).
|
||||
*
|
||||
* @param string $notificationType 코어 notification_definitions.type
|
||||
* @param string $channel 채널
|
||||
* @param array<string, mixed> $data template_code / template_name / fallback_sms_enabled / is_active
|
||||
* @return BizppurioNotificationBinding 저장된 연동
|
||||
*/
|
||||
public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding;
|
||||
|
||||
/**
|
||||
* 알림 유형+채널 연동을 삭제합니다 (연동 해제).
|
||||
*
|
||||
* @param string $notificationType 코어 notification_definitions.type
|
||||
* @param string $channel 채널
|
||||
* @return void
|
||||
*/
|
||||
public function delete(string $notificationType, string $channel = 'alimtalk'): void;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\ResultCategory;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 결과 코드 해석기 (계획서 D11).
|
||||
*
|
||||
* 발송 응답·webhook 리포트의 결과 코드를 (1) 성공/실패/재시도/잔액부족으로 분류하고,
|
||||
* (2) result_codes lang 으로 사람이 읽는 사유로 변환한다. 표시는 `사유 (코드)` 형식
|
||||
* (예: "음영 지역 (4400)"). lang 에 없는 코드는 코드만 노출한다.
|
||||
*
|
||||
* 분류 상수는 매뉴얼(부록 C-3) 1차 범위 기준이며, lang/{ko,en}/result_codes.php 와
|
||||
* 함께 유지한다.
|
||||
*/
|
||||
class ResultCodeResolver
|
||||
{
|
||||
/**
|
||||
* 성공 코드 — 발송 응답 1000 / 리포트 SMS 4100 · LMS 6600 · 알림톡 7000 / 카카오 관리 200.
|
||||
*/
|
||||
private const SUCCESS_CODES = ['1000', '4100', '6600', '7000', '200'];
|
||||
|
||||
/**
|
||||
* 재시도(일시 오류) 코드 — 큐가 재시도해야 하는 코드.
|
||||
*/
|
||||
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013'];
|
||||
|
||||
/**
|
||||
* 잔액 부족 코드 — 문자 9070 / 알림톡 7436 (D3 자체 알림 대상).
|
||||
*/
|
||||
private const BALANCE_LOW_CODES = ['9070', '7436'];
|
||||
|
||||
/**
|
||||
* 결과 코드를 카테고리로 분류합니다.
|
||||
*
|
||||
* @param string $code 결과 코드
|
||||
* @return ResultCategory 성공/재시도/잔액부족/영구실패
|
||||
*/
|
||||
public function categorize(string $code): ResultCategory
|
||||
{
|
||||
if (in_array($code, self::SUCCESS_CODES, true)) {
|
||||
return ResultCategory::Success;
|
||||
}
|
||||
|
||||
if (in_array($code, self::BALANCE_LOW_CODES, true)) {
|
||||
return ResultCategory::BalanceLow;
|
||||
}
|
||||
|
||||
if (in_array($code, self::RETRYABLE_CODES, true)) {
|
||||
return ResultCategory::Retry;
|
||||
}
|
||||
|
||||
return ResultCategory::PermanentFailure;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 코드가 성공인지 여부.
|
||||
*
|
||||
* @param string $code 결과 코드
|
||||
* @return bool
|
||||
*/
|
||||
public function isSuccess(string $code): bool
|
||||
{
|
||||
return $this->categorize($code) === ResultCategory::Success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 코드가 잔액 부족인지 여부 (D3 자체 알림 트리거).
|
||||
*
|
||||
* @param string $code 결과 코드
|
||||
* @return bool
|
||||
*/
|
||||
public function isBalanceLow(string $code): bool
|
||||
{
|
||||
return in_array($code, self::BALANCE_LOW_CODES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과 코드의 사람이 읽는 사유(로케일)를 반환합니다.
|
||||
*
|
||||
* lang 에 정의된 코드면 사유를, 없으면 null 을 반환한다.
|
||||
*
|
||||
* @param string $code 결과 코드
|
||||
* @return string|null 사유 또는 null(미정의)
|
||||
*/
|
||||
public function reason(string $code): ?string
|
||||
{
|
||||
$key = "sirsoft-message_bizppurio::result_codes.{$code}";
|
||||
$reason = __($key);
|
||||
|
||||
// __() 는 미정의 시 키 문자열을 그대로 반환 → 미정의로 판정.
|
||||
return $reason === $key ? null : $reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* 표시용 라벨 `사유 (코드)` 을 반환합니다.
|
||||
*
|
||||
* lang 에 사유가 있으면 "사유 (코드)", 없으면 코드만 반환한다.
|
||||
*
|
||||
* @param string $code 결과 코드
|
||||
* @return string 표시 라벨
|
||||
*/
|
||||
public function label(string $code): string
|
||||
{
|
||||
$reason = $this->reason($code);
|
||||
|
||||
return $reason === null ? $code : "{$reason} ({$code})";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수신자의 전화번호를 해석합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\PluginSettingsService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Concerns\PreventsReplayWebhook;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\ResultCategory;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 webhook 리포트 처리 서비스 (계획서 §5 Phase4, D3·D13).
|
||||
*
|
||||
* 컨트롤러가 검증된 리포트 값을 넘기면 상태전이·replay 멱등·잔액부족 알림을 수행한다.
|
||||
* 처리 흐름:
|
||||
* ① REFKEY 로 발송 이력 조회 — 없으면 위조로 간주하고 무시(멱등).
|
||||
* ② reported_at 이 이미 있으면 replay — 멱등 처리(상태 재변경·알림 재발송 방지).
|
||||
* ③ RESULT 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 리포트 필드를 기록.
|
||||
* ④ 잔액부족(9070/7436)이면 관리자 자체 알림을 쿨다운 내 1회만 발송(D3).
|
||||
*/
|
||||
class WebhookReportService
|
||||
{
|
||||
use PreventsReplayWebhook;
|
||||
|
||||
/** 플러그인 식별자 (환경설정 조회용) */
|
||||
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
|
||||
|
||||
/** 잔액부족 알림 쿨다운 기본값(초) — 설정 미지정 시 fallback */
|
||||
private const DEFAULT_BALANCE_LOW_COOLDOWN = 3600;
|
||||
|
||||
/**
|
||||
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리
|
||||
* @param ResultCodeResolver $resolver 결과 코드 해석기
|
||||
* @param CacheInterface $cache 잔액부족 알림 쿨다운 저장 (contextual binding 주입)
|
||||
* @param PluginSettingsService $pluginSettings 쿨다운 설정값 조회
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BizppurioDispatchRepositoryInterface $dispatches,
|
||||
private readonly ResultCodeResolver $resolver,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly PluginSettingsService $pluginSettings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* webhook 리포트 1건을 처리해 발송 이력 상태를 갱신합니다.
|
||||
*
|
||||
* @param array<string, mixed> $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<string, mixed> $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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnforceIdentityPolicy;
|
||||
use App\Http\Middleware\RefreshTokenExpiration;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@@ -12,6 +16,15 @@ use Illuminate\Support\Facades\Route;
|
||||
| - middleware: api
|
||||
*/
|
||||
|
||||
// 비즈뿌리오 webhook(URL PUSH) 리포트 수신 — 외부 시스템이 호출한다.
|
||||
//
|
||||
// api 그룹에 appendToGroup 된 토큰/IDV 미들웨어를 라우트 레벨에서 제외(코어 무수정)하고,
|
||||
// 인증은 IP 화이트리스트로 대체한다(계획서 D13). 항상 200 응답(멱등).
|
||||
Route::post('/webhook', [BizppurioWebhookController::class, 'handle'])
|
||||
->withoutMiddleware([EnforceIdentityPolicy::class, RefreshTokenExpiration::class])
|
||||
->middleware(BizppurioWebhookIpWhitelist::class)
|
||||
->name('webhook');
|
||||
|
||||
Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () {
|
||||
// 리포트 수신 주소 조회 (관리자 설정 페이지 표시용)
|
||||
//
|
||||
|
||||
+44
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+249
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Webhook;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 webhook(URL PUSH) 수신 테스트 (계획서 D13).
|
||||
*
|
||||
* IP 화이트리스트·refkey 매칭·replay 멱등·리포트 상태 반영·잔액부족 자체알림·
|
||||
* 코어 미들웨어 제외(실 POST 200)를 검증한다.
|
||||
*/
|
||||
class BizppurioWebhookTest extends PluginTestCase
|
||||
{
|
||||
private const ENDPOINT = '/api/plugins/sirsoft-message_bizppurio/webhook';
|
||||
|
||||
/** 비즈뿌리오 공식 발송 IP 중 하나 */
|
||||
private const ALLOWED_IP = '115.71.53.78';
|
||||
|
||||
/** 화이트리스트에 없는 IP */
|
||||
private const BLOCKED_IP = '203.0.113.9';
|
||||
|
||||
/**
|
||||
* 테스트 환경 캐시 드라이버가 file 이라 잔액부족 쿨다운 캐시가 실행 간 잔존한다.
|
||||
* 매 테스트 전 캐시를 비워 잔액부족 알림 중복방지 테스트 간 격리를 보장한다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* pending 상태 발송 이력 1건 생성.
|
||||
*
|
||||
* @param string $refkey 우리 부여 키
|
||||
* @param string $channel 채널
|
||||
* @return BizppurioDispatch
|
||||
*/
|
||||
private function seedDispatch(string $refkey, string $channel = 'sms'): BizppurioDispatch
|
||||
{
|
||||
return BizppurioDispatch::create([
|
||||
'refkey' => $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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* BizppurioDispatch 모델 — 캐스트·마스킹 접근자·회원 관계 검증.
|
||||
*/
|
||||
class BizppurioDispatchTest extends PluginTestCase
|
||||
{
|
||||
/**
|
||||
* enum 캐스트가 적용된다.
|
||||
*/
|
||||
public function test_enum_casts(): void
|
||||
{
|
||||
$dispatch = BizppurioDispatch::create([
|
||||
'refkey' => '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);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Repositories;
|
||||
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* BizppurioDispatchRepository — 생성·refkey 조회·갱신·필터 페이지네이션 검증.
|
||||
*/
|
||||
class BizppurioDispatchRepositoryTest extends PluginTestCase
|
||||
{
|
||||
private BizppurioDispatchRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Repositories;
|
||||
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* BizppurioNotificationBindingRepository — upsert·findActive·delete 검증.
|
||||
*/
|
||||
class BizppurioNotificationBindingRepositoryTest extends PluginTestCase
|
||||
{
|
||||
private BizppurioNotificationBindingRepository $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
|
||||
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Enums\ResultCategory;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\ResultCodeResolver;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* ResultCodeResolver — 결과코드 분류·사유·라벨 검증 (계획서 D11).
|
||||
*/
|
||||
class ResultCodeResolverTest extends PluginTestCase
|
||||
{
|
||||
private ResultCodeResolver $resolver;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
App::setLocale('ko');
|
||||
$this->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'));
|
||||
}
|
||||
}
|
||||
+42
@@ -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();
|
||||
|
||||
+73
@@ -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
|
||||
Reference in New Issue
Block a user