feat(message_bizppurio): 코어 알림 발송 이력 화면에 문자·알림톡 결과 주입
발송 결과(성공/실패·사유·잔액부족·SMS 대체발송)를 코어 "알림 발송 이력" 화면의 각 행에 표시한다. 코어 앱·코어 테이블 무수정 — 연결 표식은 bizppurio_dispatches.notification_log_id 에만 두고, 발송 사이클의 코어 after_log_sent/after_log_failed 훅으로 그 로그 id 를 연결한다. - 결과 조회는 파라미터 없는 GET /dispatch-results/recent 로 최근 결과 맵을 받아 row.id 매칭 (목록 로드 순서에 무관 — 빈 배열 전송·타이밍으로 결과가 비던 문제 해소). - 코어 이력 datagrid 에 결과 컬럼을 layout_extensions 로 주입하고, 행 토글에도 상세 결과를 얹는다. 메일·사이트내알림 탭에선 컬럼(헤더 포함)을 숨긴다. 다크모드 배지 색 보강. - 브라우저 검수용 데모 시더(4상태·엣지 케이스 21건) + 전 계층 테스트(백엔드·Vitest 렌더) + 시나리오 매니페스트 + API 레퍼런스 동반.
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 · **엔드포인트 수**: 391
|
||||
- **확장 수**: 10 · **엔드포인트 수**: 393
|
||||
|
||||
| 확장 | 유형 | 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) | 4 / 19 |
|
||||
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 5 / 21 |
|
||||
| `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,7 +16,8 @@
|
||||
- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다.
|
||||
- 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다.
|
||||
- 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.)
|
||||
- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.)
|
||||
- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등).
|
||||
- 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다.
|
||||
- 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다.
|
||||
- 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다.
|
||||
- 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.)
|
||||
|
||||
+2
@@ -33,6 +33,7 @@ return new class extends Migration
|
||||
->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->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다');
|
||||
$table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)');
|
||||
$table->string('result_code', 10)->nullable()->comment('결과 코드 (발송응답 or 리포트)');
|
||||
$table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)');
|
||||
@@ -50,6 +51,7 @@ return new class extends Migration
|
||||
$table->index('sent_at', 'bizppurio_dispatch_sent_at_idx');
|
||||
$table->index('to_user_id', 'bizppurio_dispatch_user_idx');
|
||||
$table->index(['status', 'sent_at'], 'bizppurio_dispatch_status_sent_idx');
|
||||
$table->index('notification_log_id', 'bizppurio_dispatch_notif_log_idx');
|
||||
});
|
||||
|
||||
if (DB::getDriverName() === 'mysql') {
|
||||
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Database\Seeders;
|
||||
|
||||
use App\Models\NotificationLog;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Str;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
|
||||
/**
|
||||
* A-2 데모용 일회성 시더 — 코어 알림 발송 이력 화면에서 비즈뿌리오 결과 컬럼·토글·탭 노출을 눈으로
|
||||
* 확인하기 위한 폭넓은 샘플.
|
||||
*
|
||||
* 두 종류를 넣는다:
|
||||
* (1) 비즈뿌리오 발송(sms/lms/alimtalk) — 코어 로그 + 연결된 bizppurio_dispatch. 결과 컬럼/토글에
|
||||
* 상태·`사유 (코드)`·잔액부족·대체발송 배지가 뜬다. 4가지 상태(pending/sent/success/failed)와
|
||||
* 엣지 케이스(비회원·미정의 코드·리포트 미수신·대체발송 성공/실패)를 망라한다.
|
||||
* (2) 비-비즈뿌리오 발송(mail/database) — 코어 로그만(dispatch 없음). 메일·사이트내알림 탭에 데이터가
|
||||
* 뜨고, 그 탭에선 결과 컬럼(헤더 포함)이 숨겨지는지 확인한다.
|
||||
*
|
||||
* 자동화 테스트가 아니라 PO 브라우저 검수용이며, 확인 후 지운다:
|
||||
* php artisan db:seed --class="Plugins\\Sirsoft\\MessageBizppurio\\Database\\Seeders\\DispatchResultDemoSeeder"
|
||||
* 확인 후 정리: 아래 SOURCE_MARKER 로 삽입분만 삭제(clear() 메서드 참고).
|
||||
*
|
||||
* 삽입 로그·dispatch 는 SOURCE_MARKER 로 식별되므로 실제 발송 데이터와 섞이지 않는다.
|
||||
*/
|
||||
class DispatchResultDemoSeeder extends Seeder
|
||||
{
|
||||
/** 데모 삽입분 식별 마커(source 컬럼). 정리 시 이 값으로만 삭제한다. */
|
||||
private const SOURCE_MARKER = 'bizppurio_a2_demo';
|
||||
|
||||
/**
|
||||
* 데모 로그·dispatch 를 삽입합니다.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// 재실행 시 중복되지 않도록 기존 데모분을 먼저 정리한다(source 마커 기준, 실제 데이터 무영향).
|
||||
$this->clear();
|
||||
|
||||
$bizppurioCases = $this->bizppurioCases();
|
||||
$nonBizppurioCases = $this->nonBizppurioCases();
|
||||
|
||||
foreach ($bizppurioCases as $case) {
|
||||
$log = $this->createLog($case, hasDispatch: true);
|
||||
$this->createDispatch($case, $log->id);
|
||||
}
|
||||
|
||||
foreach ($nonBizppurioCases as $case) {
|
||||
// 비-비즈뿌리오(mail/database)는 dispatch 를 만들지 않는다(결과 매칭 없음 확인용).
|
||||
$this->createLog($case, hasDispatch: false);
|
||||
}
|
||||
|
||||
$total = count($bizppurioCases) + count($nonBizppurioCases);
|
||||
$this->command?->info('[A-2 데모] 코어 알림 로그 '.$total.'건 삽입 완료 (비즈뿌리오 '.count($bizppurioCases).' + 비-비즈뿌리오 '.count($nonBizppurioCases).').');
|
||||
$this->command?->info('[A-2 데모] 전체/SMS/알림톡 탭 = 결과 컬럼 노출, 메일/사이트내알림 탭 = 결과 컬럼(헤더 포함) 숨김을 확인하세요.');
|
||||
$this->command?->info('[A-2 데모] 정리: DispatchResultDemoSeeder::clear() — source="'.self::SOURCE_MARKER.'" 로그와 연결 dispatch 만 삭제됩니다.');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비즈뿌리오 발송(sms/lms/alimtalk) 케이스 — 4가지 상태 + 엣지 케이스 망라.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function bizppurioCases(): array
|
||||
{
|
||||
return [
|
||||
// ── SMS ────────────────────────────────────────────────────────────
|
||||
['channel' => 'sms', 'type' => 'welcome', 'name' => '홍길동', 'phone' => '01011110001', 'member' => true, 'status' => 'success', 'code' => '4100', 'fallback' => null],
|
||||
['channel' => 'sms', 'type' => 'order_shipped', 'name' => '김철수', 'phone' => '01011110002', 'member' => true, 'status' => 'failed', 'code' => '4400', 'fallback' => null], // 음영 지역
|
||||
['channel' => 'sms', 'type' => 'reset_password', 'name' => '이수정', 'phone' => '01011110003', 'member' => true, 'status' => 'failed', 'code' => '4410', 'fallback' => null], // 잘못된 번호
|
||||
['channel' => 'sms', 'type' => 'order_confirmed', 'name' => '박영호', 'phone' => '01011110004', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null], // 발송요청 성공, 리포트 대기(코드 없음)
|
||||
['channel' => 'sms', 'type' => 'welcome', 'name' => '정지원', 'phone' => '01011110005', 'member' => true, 'status' => 'pending', 'code' => null, 'fallback' => null], // 발송중(pending)
|
||||
['channel' => 'sms', 'type' => 'order_confirmed', 'name' => null, 'phone' => '01011110006', 'member' => false, 'status' => 'success', 'code' => '4100', 'fallback' => null], // 비회원(수신자 링크 없음)
|
||||
['channel' => 'sms', 'type' => 'order_cancelled', 'name' => '최민아', 'phone' => '01011110007', 'member' => true, 'status' => 'failed', 'code' => '9070', 'fallback' => null], // 잔액 부족(문자)
|
||||
['channel' => 'sms', 'type' => 'order_shipped', 'name' => '강대현', 'phone' => '01011110008', 'member' => true, 'status' => 'failed', 'code' => '9999', 'fallback' => null], // 미정의 코드(사유 없이 코드만)
|
||||
|
||||
// ── LMS ────────────────────────────────────────────────────────────
|
||||
['channel' => 'lms', 'type' => 'order_confirmed', 'name' => '윤서연', 'phone' => '01011110009', 'member' => true, 'status' => 'success', 'code' => '6600', 'fallback' => null], // LMS 성공
|
||||
['channel' => 'lms', 'type' => 'order_delivered', 'name' => '임재훈', 'phone' => '01011110010', 'member' => true, 'status' => 'failed', 'code' => '6603', 'fallback' => null], // LMS 음영 지역
|
||||
|
||||
// ── 알림톡 ──────────────────────────────────────────────────────────
|
||||
['channel' => 'alimtalk', 'type' => 'order_confirmed', 'name' => '한지민', 'phone' => '01011110011', 'member' => true, 'status' => 'success', 'code' => '7000', 'fallback' => null], // 알림톡 성공
|
||||
['channel' => 'alimtalk', 'type' => 'reset_password', 'name' => '오세훈', 'phone' => '01011110012', 'member' => true, 'status' => 'success', 'code' => '7000', 'fallback' => '성공'], // 대체발송 성공
|
||||
['channel' => 'alimtalk', 'type' => 'order_shipped', 'name' => '신유진', 'phone' => '01011110013', 'member' => true, 'status' => 'failed', 'code' => '7436', 'fallback' => '실패'], // 잔액 부족 + 대체발송 실패
|
||||
['channel' => 'alimtalk', 'type' => 'welcome', 'name' => '배준영', 'phone' => '01011110014', 'member' => true, 'status' => 'failed', 'code' => '7206', 'fallback' => null], // 검수 안 된 템플릿
|
||||
['channel' => 'alimtalk', 'type' => 'order_confirmed', 'name' => null, 'phone' => '01011110015', 'member' => false, 'status' => 'success', 'code' => '7000', 'fallback' => null], // 비회원 알림톡
|
||||
['channel' => 'alimtalk', 'type' => 'password_changed', 'name' => '문가영', 'phone' => '01011110016', 'member' => true, 'status' => 'pending', 'code' => null, 'fallback' => null], // 알림톡 발송중
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 비-비즈뿌리오 발송(mail/database) 케이스 — 결과 컬럼 숨김 확인용(dispatch 없음).
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function nonBizppurioCases(): array
|
||||
{
|
||||
return [
|
||||
['channel' => 'mail', 'type' => 'welcome', 'name' => '홍보람', 'phone' => 'boram@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null],
|
||||
['channel' => 'mail', 'type' => 'reset_password', 'name' => '서다인', 'phone' => 'dain@example.com', 'member' => true, 'status' => 'failed', 'code' => null, 'fallback' => null],
|
||||
['channel' => 'mail', 'type' => 'password_changed', 'name' => '남기훈', 'phone' => 'gihun@example.com', 'member' => true, 'status' => 'skipped', 'code' => null, 'fallback' => null],
|
||||
['channel' => 'database', 'type' => 'new_comment', 'name' => '조은채', 'phone' => 'eunchae@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null],
|
||||
['channel' => 'database', 'type' => 'order_confirmed', 'name' => '권도윤', 'phone' => 'doyoon@example.com', 'member' => true, 'status' => 'sent', 'code' => null, 'fallback' => null],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 1건을 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $case 케이스 정의
|
||||
* @param bool $hasDispatch 비즈뿌리오 발송 여부(로그 본문 문구용)
|
||||
* @return NotificationLog 생성된 로그
|
||||
*/
|
||||
private function createLog(array $case, bool $hasDispatch): NotificationLog
|
||||
{
|
||||
// 코어 로그 status 는 sent/failed/skipped 만 있으므로, dispatch 의 pending/sent/success 는 sent 로 맵.
|
||||
$logStatus = match ($case['status']) {
|
||||
'failed' => 'failed',
|
||||
'skipped' => 'skipped',
|
||||
default => 'sent',
|
||||
};
|
||||
|
||||
return NotificationLog::create([
|
||||
'channel' => $case['channel'],
|
||||
'notification_type' => $case['type'],
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => $case['phone'],
|
||||
'recipient_name' => $case['name'],
|
||||
'subject' => $case['channel'] === 'mail' ? '[A-2 데모] '.$case['type'].' 메일' : null,
|
||||
'body' => '[A-2 데모] '.$case['type'].' 발송 본문 샘플'.($hasDispatch ? ' (비즈뿌리오)' : ''),
|
||||
'status' => $logStatus,
|
||||
'source' => self::SOURCE_MARKER,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 로그에 연결된 비즈뿌리오 dispatch 1건을 생성합니다.
|
||||
*
|
||||
* @param array<string, mixed> $case 케이스 정의
|
||||
* @param int $notificationLogId 연결할 코어 로그 id
|
||||
*/
|
||||
private function createDispatch(array $case, int $notificationLogId): void
|
||||
{
|
||||
BizppurioDispatch::create([
|
||||
'refkey' => 'demo_'.Str::random(26),
|
||||
'channel' => $case['channel'],
|
||||
'to_number' => $case['phone'],
|
||||
'to_name' => $case['name'],
|
||||
'to_user_id' => null, // 데모는 실제 회원 FK 연결 없음(회원/비회원 구분은 코어 로그 recipient 로 표현)
|
||||
'content' => '[A-2 데모] 발송 본문 샘플',
|
||||
'notification_type' => $case['type'],
|
||||
'notification_log_id' => $notificationLogId,
|
||||
'status' => $case['status'],
|
||||
'result_code' => $case['code'],
|
||||
'result_message' => null,
|
||||
'fallback_status' => $case['fallback'],
|
||||
'source' => 'auto',
|
||||
'sent_at' => now(),
|
||||
'reported_at' => in_array($case['status'], ['success', 'failed'], true) ? now() : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 데모 삽입분만 삭제합니다 (source 마커 기준).
|
||||
*
|
||||
* 코어 로그를 먼저 찾아 그 id 로 연결된 dispatch 를 지운 뒤 로그를 지운다. 실제 발송 데이터는
|
||||
* 이 마커를 갖지 않으므로 영향받지 않는다.
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$logIds = NotificationLog::query()->where('source', self::SOURCE_MARKER)->pluck('id')->all();
|
||||
|
||||
if ($logIds !== []) {
|
||||
BizppurioDispatch::query()->whereIn('notification_log_id', $logIds)->delete();
|
||||
NotificationLog::query()->whereIn('id', $logIds)->delete();
|
||||
}
|
||||
|
||||
$this->command?->info('[A-2 데모] 데모 로그·dispatch '.count($logIds).'건을 정리했습니다.');
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@
|
||||
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||
|
||||
<!-- @generated:start:api-readme-index -->
|
||||
- **문서 수**: 4 · **엔드포인트 수**: 19
|
||||
- **문서 수**: 5 · **엔드포인트 수**: 21
|
||||
|
||||
| 문서 | 도메인 | 엔드포인트 |
|
||||
| --- | --- | --- |
|
||||
| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 |
|
||||
| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 |
|
||||
| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 |
|
||||
| [report.md](report.md) | `report` | 1 |
|
||||
| [webhook.md](webhook.md) | `webhook` | 1 |
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
# Dispatch Results API 레퍼런스
|
||||
|
||||
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
```text
|
||||
1. 이 문서는 실제 API 호출로 실측한 Dispatch Results 엔드포인트 레퍼런스입니다
|
||||
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
|
||||
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
|
||||
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
|
||||
5. 설명(TODO) 칸은 사람이 채웁니다
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
### POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup
|
||||
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup -->
|
||||
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@lookup`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| notification_log_ids | body | array | 아니오 | max 100 | notification log 식별자 배열 |
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"notification_log_ids": [
|
||||
"예시값"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| results | array | `[]` | <!-- TODO: 설명 --> |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "messages.success",
|
||||
"data": {
|
||||
"results": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
|
||||
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명**
|
||||
|
||||
코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(layout_extensions overlay)이 소비하는 조회 API 다.
|
||||
현재 페이지에 표시된 코어 알림 로그 id 배열(`notification_log_ids`, 최대 100)을 넘기면, 그 로그에
|
||||
연결된 비즈뿌리오 발송 결과를 **로그 id 를 키로 하는 맵**으로 한 번에 돌려준다(행마다 개별 호출하지
|
||||
않아 N+1 을 피한다). 코어 알림 로그 테이블은 수정하지 않으며, 연결 표식은 비즈뿌리오 쪽
|
||||
(`bizppurio_dispatches.notification_log_id`)에만 둔다.
|
||||
|
||||
매칭되지 않는 로그 id(메일·사이트내알림 등 비-비즈뿌리오 발송)는 결과 맵에서 제외된다 — 화면에서는
|
||||
그 행의 결과 컬럼이 빈 셀이 된다. 결과에는 전화번호 등 민감정보를 포함하지 않는다(결과 컬럼은 상태·
|
||||
사유만 표시).
|
||||
|
||||
**응답 필드** (`data.results` — 키는 `notification_log_id`, 값은 결과 객체)
|
||||
|
||||
| 필드 | 타입 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| status | string\|null | 발송 상태: `sent` / `success` / `failed` (DispatchStatus) |
|
||||
| status_label | string\|null | 로케일 상태 라벨 (예: "성공", "실패", "발송중") |
|
||||
| result_code | string\|null | 결과 코드 (발송응답 또는 webhook 리포트). 리포트 미수신 시 null |
|
||||
| result_label | string\|null | `사유 (코드)` 표시 라벨 (예: "음영 지역 (4400)"). 코드 없으면 null |
|
||||
| is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 |
|
||||
| fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null |
|
||||
| channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "성공",
|
||||
"data": {
|
||||
"results": {
|
||||
"128": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "4400",
|
||||
"result_label": "음영 지역 (4400)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent
|
||||
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent -->
|
||||
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent`
|
||||
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@recent`
|
||||
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
|
||||
|
||||
**요청 파라미터**
|
||||
|
||||
_요청 파라미터 없음._
|
||||
|
||||
**요청 예시**
|
||||
|
||||
```http
|
||||
GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent HTTP/1.1
|
||||
Host: api.example.com
|
||||
Accept: application/json
|
||||
Authorization: Bearer {YOUR_TOKEN}
|
||||
```
|
||||
|
||||
**응답 필드** (`data` 내부)
|
||||
|
||||
_단건 응답: `data` 객체의 필드._
|
||||
|
||||
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
|
||||
| --- | --- | --- | --- |
|
||||
| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | <!-- TODO: 설명 --> |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
```http
|
||||
HTTP/1.1 200
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "messages.success",
|
||||
"data": {
|
||||
"results": {
|
||||
"21": {
|
||||
"status": "pending",
|
||||
"status_label": "대기",
|
||||
"result_code": null,
|
||||
"result_label": null,
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "alimtalk"
|
||||
},
|
||||
"20": {
|
||||
"status": "success",
|
||||
"status_label": "성공",
|
||||
"result_code": "7000",
|
||||
"result_label": "성공 (7000)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "alimtalk"
|
||||
},
|
||||
"19": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "7206",
|
||||
"result_label": "검수되지 않은 템플릿 (7206)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "alimtalk"
|
||||
},
|
||||
"18": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "7436",
|
||||
"result_label": "지갑 잔액 부족(알림톡) (7436)",
|
||||
"is_low_balance": true,
|
||||
"fallback_status": "실패",
|
||||
"channel": "alimtalk"
|
||||
},
|
||||
"17": {
|
||||
"status": "success",
|
||||
"status_label": "성공",
|
||||
"result_code": "7000",
|
||||
"result_label": "성공 (7000)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": "성공",
|
||||
"channel": "alimtalk"
|
||||
},
|
||||
"16": {
|
||||
"status": "success",
|
||||
"status_label": "성공",
|
||||
"result_code": "7000",
|
||||
"result_label": "성공 (7000)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "alimtalk"
|
||||
},
|
||||
"15": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "6603",
|
||||
"result_label": "음영 지역 (6603)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "lms"
|
||||
},
|
||||
"14": {
|
||||
"status": "success",
|
||||
"status_label": "성공",
|
||||
"result_code": "6600",
|
||||
"result_label": "성공 (6600)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "lms"
|
||||
},
|
||||
"13": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "9999",
|
||||
"result_label": "9999",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
},
|
||||
"12": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "9070",
|
||||
"result_label": "잔액 부족(문자) (9070)",
|
||||
"is_low_balance": true,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
},
|
||||
"11": {
|
||||
"status": "success",
|
||||
"status_label": "성공",
|
||||
"result_code": "4100",
|
||||
"result_label": "성공 (4100)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
},
|
||||
"10": {
|
||||
"status": "pending",
|
||||
"status_label": "대기",
|
||||
"result_code": null,
|
||||
"result_label": null,
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
},
|
||||
"9": {
|
||||
"status": "sent",
|
||||
"status_label": "발송중",
|
||||
"result_code": null,
|
||||
"result_label": null,
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
},
|
||||
"8": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "4410",
|
||||
"result_label": "잘못된 번호 (4410)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
},
|
||||
"7": {
|
||||
"status": "failed",
|
||||
"status_label": "실패",
|
||||
"result_code": "4400",
|
||||
"result_label": "음영 지역 (4400)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
},
|
||||
"6": {
|
||||
"status": "success",
|
||||
"status_label": "성공",
|
||||
"result_code": "4100",
|
||||
"result_label": "성공 (4100)",
|
||||
"is_low_balance": false,
|
||||
"fallback_status": null,
|
||||
"channel": "sms"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**에러 응답**
|
||||
|
||||
| 상태코드 | 의미 | 발생 조건 |
|
||||
| --- | --- | --- |
|
||||
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
|
||||
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
|
||||
|
||||
<!-- @generated:end -->
|
||||
|
||||
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
|
||||
|
||||
> **용도**: 코어 알림 편집 모달(alimtalk 채널)에 얹힌 전용 칸이 소비하는 조회·저장 API. 연결 템플릿 드롭다운/SMS 대체 토글을 바꾸면 즉시 `store` 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관, 코어 템플릿 무오염). 저장은 "빈 코드=해제" 규칙을 따른다.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR (5초 요약)
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Extension\ModuleManager;
|
||||
use Database\Seeders\NotificationDefinitionSeeder;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener;
|
||||
@@ -329,6 +330,7 @@ class Plugin extends AbstractPlugin
|
||||
* - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3)
|
||||
* - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅)
|
||||
* - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3)
|
||||
* - LinkNotificationLogListener: 코어 알림 로그↔dispatch 연결(A-2 — 발송 이력 결과 주입 연결고리)
|
||||
* - ValidateBizppurioSettingsListener: 환경설정 검증
|
||||
*
|
||||
* @return array<class-string>
|
||||
@@ -339,6 +341,7 @@ class Plugin extends AbstractPlugin
|
||||
RegisterNotificationChannelsListener::class,
|
||||
SeedChannelTemplatesListener::class,
|
||||
GuestPhoneExtractListener::class,
|
||||
LinkNotificationLogListener::class,
|
||||
ValidateBizppurioSettingsListener::class,
|
||||
];
|
||||
}
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"target_layout": "admin_notification_log_list",
|
||||
"comment": "코어 알림 발송 이력 화면에 비즈뿌리오 발송 결과 컬럼을 얹는다(A-2). 코어 화면·코어 앱·코어 테이블은 전혀 건드리지 않는다 — 연결 표식은 우리 dispatch(bizppurio_dispatches.notification_log_id)에만 있고, 현재 페이지의 코어 로그 id 배열로 결과를 배치 조회(dispatchResults)해 row.id 로 매칭한다. 매칭 안 되는 행(메일·DB 등 비-비즈뿌리오)은 빈 셀. 컬럼은 코어 datagrid columns 배열 끝에 _append 로 1개만 추가(전체 컬럼 재정의 안 함).",
|
||||
"data_sources": [
|
||||
{
|
||||
"id": "dispatchResults",
|
||||
"label_key": "$t:sirsoft-message_bizppurio.editor.data_source.dispatch_results",
|
||||
"type": "api",
|
||||
"endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent",
|
||||
"method": "GET",
|
||||
"auto_fetch": true,
|
||||
"auth_required": true,
|
||||
"loading_strategy": "progressive",
|
||||
"fallback": {
|
||||
"data": {
|
||||
"results": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"injections": [
|
||||
{
|
||||
"target_id": "notification_log_datagrid",
|
||||
"position": "inject_props",
|
||||
"comment": "코어 datagrid columns 끝에 비즈뿌리오 결과 컬럼 1개만 추가. columns 는 정적 배열이라 _append 적용 가능.",
|
||||
"props": {
|
||||
"expandChildren": {
|
||||
"_append": [
|
||||
{
|
||||
"comment": "행 토글(펼침) 안에도 비즈뿌리오 결과를 넣는다. 비즈뿌리오 발송 행(결과 매칭됨)일 때만 노출 — 메일·사이트내알림 등 매칭 안 되는 행에선 아무것도 안 보인다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}",
|
||||
"props": {
|
||||
"className": "p-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 space-y-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fas fa-comment-dots",
|
||||
"size": "sm",
|
||||
"className": "text-teal-600 dark:text-teal-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "text-sm font-medium text-gray-800 dark:text-gray-100"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.dispatch_result.detail_title"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex flex-wrap items-center gap-2"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}"
|
||||
},
|
||||
"text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"columns": {
|
||||
"_append": [
|
||||
{
|
||||
"field": "bizppurio_result",
|
||||
"header": "$t:sirsoft-message_bizppurio.dispatch_result.column_header",
|
||||
"width": "200px",
|
||||
"sortable": false,
|
||||
"required": false,
|
||||
"comment": "컬럼(헤더 포함) 자체를 탭별로 노출 제어. 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로(셀과 달리) query 접근 가능. 메일·사이트내알림(mail/database) 탭이면 hidden=true 로 컬럼 숨김. 전체·sms·lms·알림톡 탭에선 표시.",
|
||||
"hidden": "{{['mail','database'].includes(query.channel ?? '')}}",
|
||||
"cellChildren": [
|
||||
{
|
||||
"comment": "비즈뿌리오 발송 행(row.channel 이 sms/lms/alimtalk)에서만 결과 표시. 메일·사이트내알림(mail/database) 행은 셀을 비운다. 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없으므로(코어 DataGrid renderCellChildren 계약) 탭이 아니라 row.channel 로 판별한다.",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{['sms','lms','alimtalk'].includes(row.channel)}}",
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!(dispatchResults?.data?.results ?? {})[row.id]}}",
|
||||
"props": {
|
||||
"className": "text-gray-300 dark:text-gray-600"
|
||||
},
|
||||
"text": "-"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}",
|
||||
"props": {
|
||||
"className": "flex flex-col gap-1 items-start"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"props": {
|
||||
"className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}"
|
||||
},
|
||||
"text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}",
|
||||
"props": {
|
||||
"className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100"
|
||||
},
|
||||
"text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"priority": 330
|
||||
}
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 코어 알림 발송 이력 결과 컬럼 주입 렌더 테스트 (A-2)
|
||||
*
|
||||
* notification_log_result.json overlay 는 코어 "알림 발송 이력" 화면(admin_notification_log_list)의
|
||||
* DataGrid columns 에 결과 컬럼 1개를 _append 로 얹는다. 코어 화면·코어 앱·코어 테이블 무수정 —
|
||||
* 현재 페이지의 코어 로그 id 배열로 결과(dispatchResults)를 배치 조회해 row.id 로 매칭한다.
|
||||
*
|
||||
* 이 테스트는 overlay 에서 결과 컬럼 정의를 그대로 추출해 실제 렌더한다(구조 검증이 아니라 렌더).
|
||||
* row 컨텍스트는 iteration 으로 재현하고, dispatchResults 는 mockApi 로 채운다. 각 케이스에서
|
||||
* 올바른 배지 텍스트(상태 `사유 (코드)`·잔액부족·대체발송)가 DOM 에 뜨는지, 매칭 안 되는 행은
|
||||
* 빈 셀(-)인지 확인한다.
|
||||
*
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
createLayoutTest,
|
||||
createMockComponentRegistryWithBasics,
|
||||
screen,
|
||||
type MockComponentRegistry,
|
||||
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
|
||||
|
||||
import overlay from '../../../extensions/notification_log_result.json';
|
||||
|
||||
/** overlay 에서 결과 컬럼(field: bizppurio_result)의 cellChildren 을 추출한다. */
|
||||
function getResultCellChildren(): any[] {
|
||||
const inj = (overlay as any).injections.find(
|
||||
(i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props',
|
||||
);
|
||||
const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result');
|
||||
if (!column) throw new Error('결과 컬럼(bizppurio_result)을 찾지 못함');
|
||||
return column.cellChildren;
|
||||
}
|
||||
|
||||
/** 결과 컬럼 cellChildren 을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */
|
||||
function buildProbe() {
|
||||
return {
|
||||
version: '1.0.0',
|
||||
layout_name: 'test/a2-dispatch-result',
|
||||
data_sources: [
|
||||
{ id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true },
|
||||
{ id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
type: 'basic',
|
||||
name: 'Div',
|
||||
iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' },
|
||||
props: { 'data-testid': 'result-cell' },
|
||||
children: getResultCellChildren(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let registry: MockComponentRegistry;
|
||||
beforeEach(() => {
|
||||
registry = createMockComponentRegistryWithBasics();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
/** 로그 행 목록. 셀 탭 가드가 row.channel 을 보므로 채널을 함께 준다(기본 sms). */
|
||||
const logs = (ids: number[], channel: string = 'sms') => ({ data: { data: ids.map((id) => ({ id, channel })) } });
|
||||
|
||||
describe('A-2 결과 컬럼 주입 — overlay 구조', () => {
|
||||
it('코어 알림 발송 이력 datagrid 에 결과 컬럼을 _append 로 얹는다', () => {
|
||||
expect((overlay as any).target_layout).toBe('admin_notification_log_list');
|
||||
const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
|
||||
expect(inj.position).toBe('inject_props');
|
||||
expect(inj.props.columns._append).toBeTruthy();
|
||||
});
|
||||
|
||||
it('결과는 파라미터 없이 GET 으로 최근 결과 맵을 받는다(타이밍 무관, kginicis 선례)', () => {
|
||||
// 다른 data_source(notificationLogs)를 params 로 참조하면, params 가 notificationLogs 응답
|
||||
// 도착 전에 평가돼 빈 배열이 전송되는 타이밍 결함이 있다(브라우저 실측: 결과 컬럼 전부 빈 셀).
|
||||
// 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭한다(로드 순서 무관).
|
||||
const ds = (overlay as any).data_sources.find((d: any) => d.id === 'dispatchResults');
|
||||
expect(ds.method).toBe('GET');
|
||||
expect(ds.endpoint).toContain('/dispatch-results/recent');
|
||||
expect(ds.params).toBeUndefined();
|
||||
expect(ds.if).toBeUndefined();
|
||||
});
|
||||
|
||||
it('결과 컬럼 셀은 비즈뿌리오 발송 행(row.channel)에서만 표시한다(메일·사이트내알림 숨김)', () => {
|
||||
const cell = getResultCellChildren();
|
||||
const guard = cell[0];
|
||||
// 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없다(코어 DataGrid renderCellChildren 계약).
|
||||
// 따라서 탭(query.channel)이 아니라 row.channel 로 비즈뿌리오 발송 행을 판별한다.
|
||||
expect(guard.if).toContain("['sms','lms','alimtalk'].includes(row.channel)");
|
||||
});
|
||||
|
||||
it('컬럼(헤더 포함)은 메일·사이트내알림 탭에서 hidden 으로 숨긴다', () => {
|
||||
// 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로 query 접근 가능(셀과 다름).
|
||||
// 메일·사이트내알림(mail/database) 탭이면 hidden=true → 헤더까지 숨긴다.
|
||||
const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
|
||||
const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result');
|
||||
expect(column.hidden).toContain("['mail','database'].includes(query.channel");
|
||||
});
|
||||
|
||||
it('행 토글(expandChildren)에도 결과 블록을 append 한다(비즈뿌리오 발송 행만)', () => {
|
||||
const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
|
||||
const appended = inj.props.expandChildren._append;
|
||||
expect(appended).toBeTruthy();
|
||||
const block = appended[0];
|
||||
// 결과가 매칭된 행일 때만 토글에 노출.
|
||||
expect(block.if).toContain('dispatchResults?.data?.results');
|
||||
expect(JSON.stringify(block)).toContain('dispatch_result.detail_title');
|
||||
});
|
||||
|
||||
it('배지는 다크모드에서 solid 색을 쓴다(색-불투명도 희석 금지)', () => {
|
||||
const raw = JSON.stringify(overlay);
|
||||
// /40 등 색-불투명도는 작은 배지에서 다크 배경과 섞여 묽어지므로 solid(dark:bg-*-700 등) 사용.
|
||||
expect(raw).toContain('dark:bg-green-700');
|
||||
expect(raw).toContain('dark:bg-red-700');
|
||||
expect(raw).not.toContain('dark:bg-green-900/40');
|
||||
});
|
||||
});
|
||||
|
||||
describe('A-2 결과 컬럼 주입 — 렌더', () => {
|
||||
it('성공 결과는 사유(코드) 라벨을 렌더한다', async () => {
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
utils.mockApi('notificationLogs', { response: logs([1]) });
|
||||
utils.mockApi('dispatchResults', {
|
||||
response: { data: { results: { 1: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } },
|
||||
});
|
||||
await utils.render();
|
||||
expect(screen.getByText('성공 (4100)')).toBeInTheDocument();
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('잔액부족 실패는 사유(코드) 라벨과 잔액부족 배지를 렌더한다', async () => {
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
utils.mockApi('notificationLogs', { response: logs([2]) });
|
||||
utils.mockApi('dispatchResults', {
|
||||
response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '지갑 잔액 부족 (7436)', is_low_balance: true, fallback_status: null } } } },
|
||||
});
|
||||
await utils.render();
|
||||
// result_label 은 data 값(그대로 렌더). 잔액부족 배지는 is_low_balance=true 조건부 렌더 —
|
||||
// $t: 라벨은 이 렌더 환경에서 원문 키로 남으므로 그 키 존재로 배지 렌더를 확인한다.
|
||||
expect(screen.getByText('지갑 잔액 부족 (7436)')).toBeInTheDocument();
|
||||
expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).toBeInTheDocument();
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('is_low_balance=false 이면 잔액부족 배지를 렌더하지 않는다', async () => {
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
utils.mockApi('notificationLogs', { response: logs([2]) });
|
||||
utils.mockApi('dispatchResults', {
|
||||
response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '음영 지역 (4400)', is_low_balance: false, fallback_status: null } } } },
|
||||
});
|
||||
await utils.render();
|
||||
expect(screen.getByText('음영 지역 (4400)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).not.toBeInTheDocument();
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('대체발송 결과가 있으면 대체발송 배지를 렌더한다', async () => {
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
utils.mockApi('notificationLogs', { response: logs([3]) });
|
||||
utils.mockApi('dispatchResults', {
|
||||
response: { data: { results: { 3: { status: 'success', status_label: '성공', result_label: '성공 (7000)', is_low_balance: false, fallback_status: '성공' } } } },
|
||||
});
|
||||
await utils.render();
|
||||
// 대체발송 배지는 fallback_status 존재 시 조건부 렌더 ($t: 라벨은 원문 키로 남음).
|
||||
expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.fallback')).toBeInTheDocument();
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('비즈뿌리오 발송(sms) 행이지만 결과 미매칭이면 빈 셀(-)을 렌더한다', async () => {
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
utils.mockApi('notificationLogs', { response: logs([9], 'sms') });
|
||||
utils.mockApi('dispatchResults', { response: { data: { results: {} } } });
|
||||
await utils.render();
|
||||
expect(screen.getByText('-')).toBeInTheDocument();
|
||||
utils.cleanup();
|
||||
});
|
||||
|
||||
it('메일 채널 행은 셀 자체를 비운다(빈 셀 - 도 표시 안 함)', async () => {
|
||||
// row.channel 이 mail 이면 셀 최상위 가드가 false → 셀 내용(빈 셀 - 포함) 전체 미렌더.
|
||||
const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
|
||||
utils.mockApi('notificationLogs', { response: logs([10], 'mail') });
|
||||
utils.mockApi('dispatchResults', { response: { data: { results: {} } } });
|
||||
await utils.render();
|
||||
expect(screen.queryByText('-')).not.toBeInTheDocument();
|
||||
utils.cleanup();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 비즈뿌리오 플러그인 레이아웃 렌더 테스트 환경 설정.
|
||||
*
|
||||
* jest-dom matcher(toBeInTheDocument 등)를 로드하고, jsdom 전역 mock 을 준비한다.
|
||||
* 코어 레이아웃 렌더 테스트(createLayoutTest)를 사용하는 테스트가 소비한다.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -84,6 +84,18 @@
|
||||
"setup_action": "Set up",
|
||||
"test_mode": "Test mode — messages are not actually sent."
|
||||
},
|
||||
"dispatch_result": {
|
||||
"column_header": "SMS/Alimtalk Result",
|
||||
"low_balance": "Low balance",
|
||||
"fallback": "SMS fallback {status}",
|
||||
"detail_title": "SMS/Alimtalk delivery result",
|
||||
"channel_label": "Channel: {channel}"
|
||||
},
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"dispatch_results": "Bizppurio dispatch results"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"title": "Alimtalk Templates",
|
||||
"description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.",
|
||||
|
||||
@@ -84,6 +84,18 @@
|
||||
"setup_action": "설정하기",
|
||||
"test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다."
|
||||
},
|
||||
"dispatch_result": {
|
||||
"column_header": "문자·알림톡 결과",
|
||||
"low_balance": "잔액 부족",
|
||||
"fallback": "SMS 대체발송 {status}",
|
||||
"detail_title": "문자·알림톡 발송 결과",
|
||||
"channel_label": "발송 채널: {channel}"
|
||||
},
|
||||
"editor": {
|
||||
"data_source": {
|
||||
"dispatch_results": "비즈뿌리오 발송 결과"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"title": "알림톡 템플릿",
|
||||
"description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.",
|
||||
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\DispatchResultLookupRequest;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchResultService;
|
||||
|
||||
/**
|
||||
* 코어 알림 발송 이력 화면용 비즈뿌리오 발송 결과 조회 컨트롤러 (A-2 표시 주입).
|
||||
*
|
||||
* 코어 "알림 발송 이력" 화면(sirsoft-admin_basic)에 plugin overlay 로 얹은 결과 컬럼이, 현재
|
||||
* 페이지의 코어 알림 로그 id 배열을 이 API 로 넘겨 비즈뿌리오 결과(상태·사유·잔액부족·대체발송)를
|
||||
* 한 번에 조회한다. 코어 화면·코어 앱·코어 테이블은 전혀 건드리지 않는다(연결 표식은 우리 dispatch
|
||||
* 쪽에만 있고, 이 API 가 그것을 읽어 로그 id 키 맵으로 돌려준다).
|
||||
*
|
||||
* 권한(라우트 미들웨어): 조회 = messaging.view.
|
||||
*/
|
||||
class DispatchResultController extends AdminBaseController
|
||||
{
|
||||
/**
|
||||
* @param DispatchResultService $service 결과 조회 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly DispatchResultService $service,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 id 배열에 대한 비즈뿌리오 결과 맵을 반환합니다.
|
||||
*
|
||||
* @param DispatchResultLookupRequest $request 검증된 로그 id 목록
|
||||
* @return JsonResponse data.results 에 notification_log_id → 결과 맵
|
||||
*/
|
||||
public function lookup(DispatchResultLookupRequest $request): JsonResponse
|
||||
{
|
||||
return ResponseHelper::success('messages.success', [
|
||||
'results' => $this->service->resultsForLogIds($request->logIds()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 연결된 비즈뿌리오 결과 맵을 반환합니다 (코어 이력 화면 결과 컬럼용, 타이밍 무관).
|
||||
*
|
||||
* 파라미터 없이 GET 으로 최근 결과 맵을 받아 화면이 row.id 로 매칭한다(kginicis test-map 선례).
|
||||
* 목록 data_source(notificationLogs) 로드 순서에 의존하지 않아, 결과 컬럼이 비는 문제를 원천 차단.
|
||||
*
|
||||
* @return JsonResponse data.results 에 notification_log_id → 결과 맵
|
||||
*/
|
||||
public function recent(): JsonResponse
|
||||
{
|
||||
return ResponseHelper::success('messages.success', [
|
||||
'results' => $this->service->recentResults(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* 코어 알림 발송 이력 결과 배치 조회 검증 (A-2 표시 주입).
|
||||
*
|
||||
* 코어 알림 발송 이력 화면이 현재 페이지의 코어 알림 로그 id 배열을 넘겨 비즈뿌리오 결과를 한 번에
|
||||
* 조회한다. 한 페이지(최대 100건) 분량으로 배열 크기를 제한해 과도한 조회를 막는다.
|
||||
*/
|
||||
class DispatchResultLookupRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한은 라우트 미들웨어(messaging.view)에서 처리한다.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 규칙을 반환합니다.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// 빈 배열 허용: 코어 알림 발송 이력이 0건(또는 로드 전)이면 화면이 빈 배열을 보낸다.
|
||||
// 이때 422 로 막으면 결과 컬럼 조회 자체가 에러가 되므로, present(키 존재)만 요구하고
|
||||
// 빈 배열은 통과시켜 빈 결과 맵을 돌려준다.
|
||||
'notification_log_ids' => ['present', 'array', 'max:100'],
|
||||
'notification_log_ids.*' => ['integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증된 코어 알림 로그 id 목록을 정수 배열로 반환합니다.
|
||||
*
|
||||
* @return array<int, int> 로그 id 목록
|
||||
*/
|
||||
public function logIds(): array
|
||||
{
|
||||
return array_map('intval', $this->validated('notification_log_ids', []));
|
||||
}
|
||||
}
|
||||
+2
@@ -25,6 +25,8 @@ class StoreAlimtalkTemplateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 검사는 라우트 미들웨어가 담당 — 항상 통과.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
||||
+2
@@ -16,6 +16,8 @@ class StoreNotificationBindingRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
||||
+2
@@ -18,6 +18,8 @@ class UploadTemplateImageRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* 권한 검사는 라우트 미들웨어가 담당 — 항상 통과.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
|
||||
|
||||
use App\Contracts\Extension\HookListenerInterface;
|
||||
use App\Models\NotificationLog;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 ↔ 비즈뿌리오 발송건 연결 리스너 (A-2 연결고리).
|
||||
*
|
||||
* 코어 알림 발송 이력 화면에서 비즈뿌리오 결과(성공/실패·사유·잔액부족·대체발송)를 그 행에
|
||||
* 붙이려면, 코어 로그(notification_logs) 와 우리 dispatch(bizppurio_dispatches) 를 이어야 한다.
|
||||
* 코어 로그 테이블 무수정 원칙(계획서 ⚑ 결정 1)에 따라, 연결 표식은 우리 dispatch 쪽에만 둔다
|
||||
* (bizppurio_dispatches.notification_log_id).
|
||||
*
|
||||
* 코어가 로그 생성 직후 발화하는 확장점을 구독한다:
|
||||
* - core.notification_log.after_log_sent — 발송 성공 로그 생성 직후(NotificationLog 전달)
|
||||
* - core.notification_log.after_log_failed — 발송 실패 로그 생성 직후(NotificationLog 전달)
|
||||
*
|
||||
* 매칭 방식(A안, PO 확정): 복합키 근사 매칭이 아니라 발송 사이클 refkey 직접 표식이다.
|
||||
* 같은 채널 발송 한 사이클에서 우리 드라이버 send() 가 dispatch(refkey) 를 먼저 만들고
|
||||
* DispatchLinkContext 에 refkey 를 남긴 뒤, 곧바로 이 로그 훅이 발화한다. 여기서 그 refkey 를
|
||||
* 꺼내(consume) 그 dispatch 에 방금 만들어진 코어 로그 id 를 기록한다.
|
||||
*
|
||||
* 비-비즈뿌리오 채널(mail/database 등)의 로그가 발화한 경우엔 이 사이클에서 remember 된 refkey 가
|
||||
* 없으므로 consume() 이 null 을 반환하고 조용히 넘어간다(그 로그는 연결 대상 아님).
|
||||
*/
|
||||
class LinkNotificationLogListener implements HookListenerInterface
|
||||
{
|
||||
/**
|
||||
* @param BizppurioDispatchRepositoryInterface $dispatches dispatch 연결 위임
|
||||
* @param DispatchLinkContext $linkContext 발송 사이클 refkey 컨텍스트
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BizppurioDispatchRepositoryInterface $dispatches,
|
||||
private readonly DispatchLinkContext $linkContext,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 구독할 훅 목록 반환.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.notification_log.after_log_sent' => ['method' => 'linkLog', 'priority' => 15],
|
||||
'core.notification_log.after_log_failed' => ['method' => 'linkLog', 'priority' => 15],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 핸들러 (미사용 — linkLog 로 처리).
|
||||
*
|
||||
* @param mixed ...$args
|
||||
*/
|
||||
public function handle(...$args): void {}
|
||||
|
||||
/**
|
||||
* 방금 생성된 코어 알림 로그를 이번 발송 사이클의 dispatch 에 연결합니다.
|
||||
*
|
||||
* 이 사이클에서 우리 드라이버가 남긴 refkey 가 있으면 그 dispatch 에 로그 id 를 기록한다.
|
||||
* refkey 가 없으면(비-비즈뿌리오 로그) 연결하지 않는다.
|
||||
*
|
||||
* @param NotificationLog $log 코어가 방금 생성한 로그(id 포함)
|
||||
*/
|
||||
public function linkLog(NotificationLog $log): void
|
||||
{
|
||||
$refkey = $this->linkContext->consume();
|
||||
|
||||
if ($refkey === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->dispatches->linkNotificationLog($refkey, (int) $log->id);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('비즈뿌리오: 코어 알림 로그 연결 실패', [
|
||||
'refkey' => $refkey,
|
||||
'notification_log_id' => $log->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Models;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -28,16 +29,17 @@ use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
|
||||
* @property int|null $to_user_id
|
||||
* @property string $content
|
||||
* @property string|null $notification_type
|
||||
* @property int|null $notification_log_id
|
||||
* @property string $status
|
||||
* @property string|null $result_code
|
||||
* @property string|null $result_message
|
||||
* @property string|null $fallback_status
|
||||
* @property string $source
|
||||
* @property \Carbon\Carbon|null $sent_at
|
||||
* @property \Carbon\Carbon|null $reported_at
|
||||
* @property Carbon|null $sent_at
|
||||
* @property Carbon|null $reported_at
|
||||
* @property array|null $raw_payload
|
||||
* @property \Carbon\Carbon|null $created_at
|
||||
* @property \Carbon\Carbon|null $updated_at
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read User|null $user
|
||||
*/
|
||||
class BizppurioDispatch extends Model
|
||||
@@ -64,6 +66,7 @@ class BizppurioDispatch extends Model
|
||||
'to_user_id',
|
||||
'content',
|
||||
'notification_type',
|
||||
'notification_log_id',
|
||||
'status',
|
||||
'result_code',
|
||||
'result_message',
|
||||
@@ -86,6 +89,7 @@ class BizppurioDispatch extends Model
|
||||
'status' => DispatchStatus::class,
|
||||
'source' => DispatchSource::class,
|
||||
'to_user_id' => 'integer',
|
||||
'notification_log_id' => 'integer',
|
||||
'sent_at' => 'datetime',
|
||||
'reported_at' => 'datetime',
|
||||
'raw_payload' => 'array',
|
||||
|
||||
+15
@@ -12,6 +12,7 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRep
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService;
|
||||
|
||||
@@ -51,6 +52,20 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider
|
||||
WebhookReportService::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* 서비스 컨테이너 바인딩을 등록합니다.
|
||||
*
|
||||
* DispatchLinkContext 는 한 발송 사이클(HTTP 요청/큐 잡) 안에서 refkey↔코어 로그 연결을
|
||||
* 잇기 위해 상태를 공유해야 하므로 scoped(요청 단위 싱글턴)로 바인딩한다. 채널 드라이버와
|
||||
* LinkNotificationLogListener 가 같은 인스턴스를 주입받아 refkey 를 주고받는다(A-2).
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
parent::register();
|
||||
|
||||
$this->app->scoped(DispatchLinkContext::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 루트 lang 디렉토리를 로드합니다.
|
||||
*
|
||||
|
||||
+56
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Repositories;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
|
||||
|
||||
@@ -87,4 +88,59 @@ class BizppurioDispatchRepository implements BizppurioDispatchRepositoryInterfac
|
||||
|
||||
return $query->orderByDesc('created_at')->paginate($perPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리).
|
||||
*
|
||||
* @param string $refkey 발송 사이클에서 부여한 refkey
|
||||
* @param int $notificationLogId 코어 notification_logs.id
|
||||
* @return bool 연결 성공 여부(대상 없으면 false)
|
||||
*/
|
||||
public function linkNotificationLog(string $refkey, int $notificationLogId): bool
|
||||
{
|
||||
$dispatch = BizppurioDispatch::query()->byRefkey($refkey)->first();
|
||||
|
||||
if ($dispatch === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$dispatch->notification_log_id = $notificationLogId;
|
||||
$dispatch->save();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시).
|
||||
*
|
||||
* @param array<int, int> $notificationLogIds 코어 로그 id 목록
|
||||
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
|
||||
*/
|
||||
public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection
|
||||
{
|
||||
if ($notificationLogIds === []) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return BizppurioDispatch::query()
|
||||
->whereIn('notification_log_id', $notificationLogIds)
|
||||
->get()
|
||||
->keyBy('notification_log_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
|
||||
*
|
||||
* @param int $limit 최근 dispatch 조회 상한
|
||||
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
|
||||
*/
|
||||
public function recentLinkedKeyed(int $limit = 1000): Collection
|
||||
{
|
||||
return BizppurioDispatch::query()
|
||||
->whereNotNull('notification_log_id')
|
||||
->orderByDesc('id')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->keyBy('notification_log_id');
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts;
|
||||
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
|
||||
/**
|
||||
@@ -48,4 +49,40 @@ interface BizppurioDispatchRepositoryInterface
|
||||
* @return LengthAwarePaginator<BizppurioDispatch>
|
||||
*/
|
||||
public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리).
|
||||
*
|
||||
* LinkNotificationLogListener 가 발송 사이클 직후 코어 로그 id 를 이 dispatch 에 기록할 때
|
||||
* 호출한다. refkey 가 없거나(비-비즈뿌리오 로그) 이미 연결됐으면 아무 것도 하지 않는다.
|
||||
*
|
||||
* @param string $refkey 발송 사이클에서 부여한 refkey
|
||||
* @param int $notificationLogId 코어 notification_logs.id
|
||||
* @return bool 연결 성공 여부(대상 없으면 false)
|
||||
*/
|
||||
public function linkNotificationLog(string $refkey, int $notificationLogId): bool;
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시).
|
||||
*
|
||||
* 코어 알림 발송 이력 화면이 현재 페이지의 log id 배열로 비즈뿌리오 결과를 한 번에 조회한다
|
||||
* (N+1 회피). 매칭되지 않는 log id(메일·DB 등 비-비즈뿌리오)는 맵에서 빠진다.
|
||||
*
|
||||
* @param array<int, int> $notificationLogIds 코어 로그 id 목록
|
||||
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
|
||||
*/
|
||||
public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection;
|
||||
|
||||
/**
|
||||
* 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
|
||||
*
|
||||
* 화면이 파라미터 없이 GET 으로 최근 결과 맵을 받아 row.id 로 매칭한다(kginicis test-map 선례).
|
||||
* params 로 로그 id 를 넘기지 않으므로 목록 data_source 로드 순서에 의존하지 않는다. 이력 화면은
|
||||
* 페이지네이션(20건)이라 최근 N건 맵으로 현재 화면을 덮는다. notification_log_id 가 없는
|
||||
* dispatch(아직 연결 전)는 제외한다.
|
||||
*
|
||||
* @param int $limit 최근 dispatch 조회 상한
|
||||
* @return Collection<int, BizppurioDispatch> notification_log_id 를 키로 하는 dispatch 맵
|
||||
*/
|
||||
public function recentLinkedKeyed(int $limit = 1000): Collection;
|
||||
}
|
||||
|
||||
@@ -51,12 +51,14 @@ class AlimtalkChannelDriver
|
||||
* @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회
|
||||
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
|
||||
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화
|
||||
* @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly NotificationTemplateService $templateService,
|
||||
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
|
||||
private readonly MessagePayloadBuilder $payloadBuilder,
|
||||
private readonly BizppurioDispatchRepositoryInterface $dispatches,
|
||||
private readonly DispatchLinkContext $linkContext,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -132,6 +134,10 @@ class AlimtalkChannelDriver
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록
|
||||
// refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다.
|
||||
$this->linkContext->remember($refkey);
|
||||
|
||||
SendMessageJob::dispatch($payload, $refkey);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
/**
|
||||
* 발송 사이클 내 refkey ↔ 코어 알림 로그 연결을 잇는 request-scoped 컨텍스트 (A-2).
|
||||
*
|
||||
* 코어 알림 발송 한 사이클의 실행 순서는 다음과 같다(코드 확인):
|
||||
* before_channel_send
|
||||
* → 채널 드라이버 send() : bizppurio_dispatch pending 생성(refkey 부여) → 여기에 refkey 기록
|
||||
* after_channel_send
|
||||
* → 코어 NotificationLogListener : NotificationLog 생성(id 부여)
|
||||
* → core.notification_log.after_log_sent / after_log_failed 훅
|
||||
* → LinkNotificationLogListener : 여기서 최근 refkey 를 꺼내 그 dispatch 에 log id 기록
|
||||
*
|
||||
* 드라이버(send)와 링크 리스너(after_log_sent)는 같은 채널 발송 사이클 안에서 순차 실행되므로,
|
||||
* "가장 최근에 부여한 refkey" 를 그 로그와 1:1 로 연결할 수 있다. 복합키(type+user+channel+
|
||||
* 시각) 근사 매칭은 동일 시각 중복 발송 시 오매칭 위험이 있어 쓰지 않는다.
|
||||
*
|
||||
* 컨테이너에 싱글턴으로 바인딩되어 한 HTTP 요청/큐 잡 안에서만 상태를 공유한다.
|
||||
* remember() 로 refkey 를 넣고 consume() 로 꺼낸다(꺼내면 비운다 — 다음 사이클로 새지 않게).
|
||||
*/
|
||||
class DispatchLinkContext
|
||||
{
|
||||
/** 이번 발송 사이클에서 드라이버가 방금 부여한 refkey (없으면 null). */
|
||||
private ?string $pendingRefkey = null;
|
||||
|
||||
/**
|
||||
* 드라이버가 dispatch pending 을 만든 직후 그 refkey 를 기록합니다.
|
||||
*
|
||||
* @param string $refkey 방금 부여한 refkey
|
||||
*/
|
||||
public function remember(string $refkey): void
|
||||
{
|
||||
$this->pendingRefkey = $refkey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 링크 리스너가 최근 refkey 를 꺼냅니다 (꺼내면 비웁니다).
|
||||
*
|
||||
* 비-비즈뿌리오 채널(mail/database 등)의 로그가 발화한 경우엔 이 사이클에서 remember 된
|
||||
* refkey 가 없으므로 null 을 반환한다(그 로그는 연결 대상 아님).
|
||||
*
|
||||
* @return string|null 최근 refkey 또는 null
|
||||
*/
|
||||
public function consume(): ?string
|
||||
{
|
||||
$refkey = $this->pendingRefkey;
|
||||
$this->pendingRefkey = null;
|
||||
|
||||
return $refkey;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Services;
|
||||
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
|
||||
|
||||
/**
|
||||
* 코어 알림 발송 이력 화면에 붙일 비즈뿌리오 발송 결과 조회 서비스 (A-2 표시 주입).
|
||||
*
|
||||
* 코어 화면이 현재 페이지의 코어 알림 로그 id 배열을 넘기면, 그 id 로 연결된 dispatch 를 일괄
|
||||
* 조회해(N+1 회피) 로그 id 를 키로 하는 결과 맵을 만든다. 결과 코드 사유·분류는 ResultCodeResolver
|
||||
* 를 재사용한다. 매칭되지 않는 로그(메일·DB 등 비-비즈뿌리오)는 맵에서 빠져 화면에서 빈 셀이 된다.
|
||||
*
|
||||
* 이 서비스는 표시용 파생 값(상태 라벨·`사유 (코드)`·잔액부족 여부·대체발송 라벨)만 계산하며,
|
||||
* 전화번호 등 민감정보는 다루지 않는다(A-2 결과 컬럼엔 전화번호를 노출하지 않음).
|
||||
*/
|
||||
class DispatchResultService
|
||||
{
|
||||
/**
|
||||
* @param BizppurioDispatchRepositoryInterface $dispatches dispatch 배치 조회
|
||||
* @param ResultCodeResolver $resultCodes 결과 코드 사유·분류 해석
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly BizppurioDispatchRepositoryInterface $dispatches,
|
||||
private readonly ResultCodeResolver $resultCodes,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 id 배열에 대한 비즈뿌리오 결과 맵을 반환합니다.
|
||||
*
|
||||
* @param array<int, int> $notificationLogIds 코어 로그 id 목록(현재 페이지)
|
||||
* @return array<int, array<string, mixed>> notification_log_id → 결과 표시 데이터
|
||||
*/
|
||||
public function resultsForLogIds(array $notificationLogIds): array
|
||||
{
|
||||
$dispatchMap = $this->dispatches->findByNotificationLogIdsKeyed($notificationLogIds);
|
||||
|
||||
return $this->buildResultMap($dispatchMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 연결된 dispatch 결과를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
|
||||
*
|
||||
* 화면이 파라미터 없이 조회해 row.id 로 매칭한다(kginicis test-map 선례). 목록 data_source
|
||||
* 로드 순서에 의존하지 않아 결과 컬럼이 빈 배열로 비는 문제를 원천 차단한다.
|
||||
*
|
||||
* @param int $limit 최근 dispatch 조회 상한
|
||||
* @return array<int, array<string, mixed>> notification_log_id → 결과 표시 데이터
|
||||
*/
|
||||
public function recentResults(int $limit = 1000): array
|
||||
{
|
||||
return $this->buildResultMap($this->dispatches->recentLinkedKeyed($limit));
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatch 맵을 log id 키의 결과 표시 배열 맵으로 변환합니다.
|
||||
*
|
||||
* @param \Illuminate\Support\Collection<int, BizppurioDispatch> $dispatchMap log id 키 dispatch 맵
|
||||
* @return array<int, array<string, mixed>> notification_log_id → 결과 표시 데이터
|
||||
*/
|
||||
private function buildResultMap(\Illuminate\Support\Collection $dispatchMap): array
|
||||
{
|
||||
$results = [];
|
||||
|
||||
foreach ($dispatchMap as $logId => $dispatch) {
|
||||
$results[(int) $logId] = $this->buildResult($dispatch);
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* dispatch 1건을 화면 표시용 결과 배열로 변환합니다.
|
||||
*
|
||||
* @param BizppurioDispatch $dispatch 연결된 발송 이력
|
||||
* @return array<string, mixed> 상태·결과 라벨·잔액부족·대체발송 표시 데이터
|
||||
*/
|
||||
private function buildResult(BizppurioDispatch $dispatch): array
|
||||
{
|
||||
$code = $dispatch->result_code;
|
||||
$hasCode = $code !== null && $code !== '';
|
||||
|
||||
return [
|
||||
'status' => $dispatch->status?->value,
|
||||
'status_label' => $dispatch->status?->label(),
|
||||
'result_code' => $code,
|
||||
// `사유 (코드)` 표시 라벨. 코드가 없으면(리포트 미수신) null → 화면은 상태 라벨만 표시.
|
||||
'result_label' => $hasCode ? $this->resultCodes->label($code) : null,
|
||||
'is_low_balance' => $hasCode && $this->resultCodes->isBalanceLow($code),
|
||||
'fallback_status' => $dispatch->fallback_status,
|
||||
'channel' => $dispatch->channel?->value,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,14 @@ class SmsChannelDriver
|
||||
* @param SmsTypeResolver $typeResolver SMS/LMS byte 판별
|
||||
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
|
||||
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4)
|
||||
* @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2)
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly NotificationTemplateService $templateService,
|
||||
private readonly SmsTypeResolver $typeResolver,
|
||||
private readonly MessagePayloadBuilder $payloadBuilder,
|
||||
private readonly BizppurioDispatchRepositoryInterface $dispatches,
|
||||
private readonly DispatchLinkContext $linkContext,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -118,6 +120,10 @@ class SmsChannelDriver
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록
|
||||
// refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다.
|
||||
$this->linkContext->remember($refkey);
|
||||
|
||||
SendMessageJob::dispatch($payload, $refkey);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Http\Middleware\EnforceIdentityPolicy;
|
||||
use App\Http\Middleware\RefreshTokenExpiration;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist;
|
||||
@@ -106,4 +107,24 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
|
||||
Route::post('/', [NotificationBindingController::class, 'store'])->name('store');
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| 코어 알림 발송 이력 결과 조회 (A-2) — 코어 이력 화면 overlay 가 소비
|
||||
|----------------------------------------------------------------------
|
||||
|
|
||||
| 코어 "알림 발송 이력" 화면에 plugin overlay 로 얹은 결과 컬럼이, 현재 페이지의 코어 알림
|
||||
| 로그 id 배열을 이 API 로 넘겨 비즈뿌리오 결과(상태·사유·잔액부족·대체발송)를 한 번에 조회
|
||||
| 한다(N+1 회피). 코어 화면·코어 테이블 무수정 — 연결 표식은 우리 dispatch 쪽에만 둔다.
|
||||
|
|
||||
| 조회 = messaging.view.
|
||||
*/
|
||||
Route::prefix('dispatch-results')->name('dispatch-results.')->group(function () {
|
||||
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () {
|
||||
// 화면 결과 컬럼용: 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭(타이밍 무관, 기본 경로).
|
||||
Route::get('/recent', [DispatchResultController::class, 'recent'])->name('recent');
|
||||
// 명시 로그 id 배열 조회(직접 조회용). 화면은 recent 를 쓴다.
|
||||
Route::post('/lookup', [DispatchResultController::class, 'lookup'])->name('lookup');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification;
|
||||
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\Permission;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 코어 알림 발송 이력 결과 조회 엔드포인트 테스트 (A-2 표시 주입).
|
||||
*
|
||||
* 코어 "알림 발송 이력" 화면에 얹은 결과 컬럼이 현재 페이지의 코어 알림 로그 id 배열을 넘겨
|
||||
* 비즈뿌리오 결과(상태·사유·잔액부족·대체발송)를 한 번에 조회한다. 조회는 messaging.view 권한을
|
||||
* 요구하고, 매칭되지 않는 로그 id 는 결과 맵에서 빠진다(빈 셀).
|
||||
*/
|
||||
class DispatchResultEndpointTest extends PluginTestCase
|
||||
{
|
||||
private const URL = '/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup';
|
||||
|
||||
/**
|
||||
* 지정 권한을 가진 admin 사용자를 만듭니다.
|
||||
*
|
||||
* @param array<int, string> $permissionIds 부여할 권한 식별자
|
||||
*/
|
||||
private function adminWith(array $permissionIds): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$adminRole = Role::firstOrCreate(
|
||||
['identifier' => 'admin'],
|
||||
['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
|
||||
);
|
||||
|
||||
$permIds = [];
|
||||
foreach ($permissionIds as $identifier) {
|
||||
$permission = Permission::firstOrCreate(
|
||||
['identifier' => $identifier],
|
||||
['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
|
||||
);
|
||||
$permIds[] = $permission->id;
|
||||
}
|
||||
|
||||
$testRole = Role::create([
|
||||
'identifier' => 'bizppurio_result_test_'.uniqid(),
|
||||
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
|
||||
'type' => 'admin',
|
||||
]);
|
||||
$testRole->permissions()->sync($permIds);
|
||||
|
||||
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
|
||||
|
||||
return $user->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 토큰 헤더를 만듭니다.
|
||||
*/
|
||||
private function authHeaders(User $user): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken,
|
||||
'Accept' => 'application/json',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 1건 + 연결된 dispatch 1건을 만들고 로그 id 를 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $dispatchAttrs dispatch 오버라이드
|
||||
*/
|
||||
private function seedLinkedLog(array $dispatchAttrs = []): int
|
||||
{
|
||||
$log = NotificationLog::create([
|
||||
'channel' => 'sms',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => '01011112222',
|
||||
'recipient_name' => '홍길동',
|
||||
'status' => 'sent',
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
BizppurioDispatch::create(array_merge([
|
||||
'refkey' => 'rk_'.uniqid(),
|
||||
'channel' => 'sms',
|
||||
'to_number' => '01011112222',
|
||||
'content' => '본문',
|
||||
'notification_type' => 'welcome',
|
||||
'notification_log_id' => $log->id,
|
||||
'status' => 'success',
|
||||
'result_code' => '4100',
|
||||
'source' => 'auto',
|
||||
'sent_at' => now(),
|
||||
], $dispatchAttrs));
|
||||
|
||||
return (int) $log->id;
|
||||
}
|
||||
|
||||
private const RECENT_URL = '/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent';
|
||||
|
||||
public function test_인증_없이_조회는_401이다(): void
|
||||
{
|
||||
$this->postJson(self::URL, ['notification_log_ids' => [1]])->assertStatus(401);
|
||||
}
|
||||
|
||||
public function test_recent_는_view_권한_없으면_403이다(): void
|
||||
{
|
||||
$user = $this->adminWith([]);
|
||||
|
||||
$this->getJson(self::RECENT_URL, $this->authHeaders($user))->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_recent_는_파라미터_없이_최근_결과맵을_반환한다(): void
|
||||
{
|
||||
$user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
$logId = $this->seedLinkedLog(['result_code' => '4100']);
|
||||
|
||||
$result = $this->getJson(self::RECENT_URL, $this->authHeaders($user))
|
||||
->assertOk()
|
||||
->json("data.results.{$logId}");
|
||||
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame('4100', $result['result_code']);
|
||||
}
|
||||
|
||||
public function test_view_권한_없으면_403이다(): void
|
||||
{
|
||||
$user = $this->adminWith([]); // 권한 없음
|
||||
$logId = $this->seedLinkedLog();
|
||||
|
||||
$this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
|
||||
->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_로그_id_키가_아예_없으면_422다(): void
|
||||
{
|
||||
$user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
|
||||
$this->postJson(self::URL, [], $this->authHeaders($user))
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['notification_log_ids']);
|
||||
}
|
||||
|
||||
public function test_빈_배열은_200과_빈_결과맵을_반환한다(): void
|
||||
{
|
||||
// 코어 알림 발송 이력이 0건(또는 아직 미로드)일 때 화면은 빈 배열을 보낸다.
|
||||
// 이 경우 422 가 아니라 200 + 빈 결과 맵이어야 화면이 깨지지 않는다(회귀: 브라우저 실측).
|
||||
$user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
|
||||
$response = $this->postJson(self::URL, ['notification_log_ids' => []], $this->authHeaders($user))
|
||||
->assertOk();
|
||||
|
||||
$this->assertSame([], $response->json('data.results'));
|
||||
}
|
||||
|
||||
public function test_성공_결과를_로그id_키_맵으로_반환한다(): void
|
||||
{
|
||||
$user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
$logId = $this->seedLinkedLog();
|
||||
|
||||
$response = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
|
||||
->assertOk();
|
||||
|
||||
$result = $response->json("data.results.{$logId}");
|
||||
$this->assertSame('success', $result['status']);
|
||||
$this->assertSame('4100', $result['result_code']);
|
||||
$this->assertFalse($result['is_low_balance']);
|
||||
}
|
||||
|
||||
public function test_실패_음영지역은_사유_코드_라벨을_반환한다(): void
|
||||
{
|
||||
$user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
$logId = $this->seedLinkedLog(['status' => 'failed', 'result_code' => '4400']);
|
||||
|
||||
$result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
|
||||
->assertOk()
|
||||
->json("data.results.{$logId}");
|
||||
|
||||
// result_label 은 `사유 (코드)` 형식 — result_codes lang 에 4400 정의가 있으면 사유가 붙는다.
|
||||
$this->assertSame('failed', $result['status']);
|
||||
$this->assertStringContainsString('4400', (string) $result['result_label']);
|
||||
}
|
||||
|
||||
public function test_잔액부족_코드는_is_low_balance_true다(): void
|
||||
{
|
||||
$user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
$logId = $this->seedLinkedLog(['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']);
|
||||
|
||||
$result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
|
||||
->assertOk()
|
||||
->json("data.results.{$logId}");
|
||||
|
||||
$this->assertTrue($result['is_low_balance']);
|
||||
}
|
||||
|
||||
public function test_매칭되지_않는_로그id는_결과맵에서_빠진다(): void
|
||||
{
|
||||
$user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
|
||||
$linkedId = $this->seedLinkedLog();
|
||||
|
||||
// 연결되지 않은 코어 로그(메일 등) — dispatch 없음
|
||||
$mailLog = NotificationLog::create([
|
||||
'channel' => 'mail',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => 'a@b.com',
|
||||
'status' => 'sent',
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$results = $this->postJson(
|
||||
self::URL,
|
||||
['notification_log_ids' => [$linkedId, $mailLog->id]],
|
||||
$this->authHeaders($user)
|
||||
)->assertOk()->json('data.results');
|
||||
|
||||
$this->assertArrayHasKey((string) $linkedId, $results);
|
||||
$this->assertArrayNotHasKey((string) $mailLog->id, $results);
|
||||
}
|
||||
}
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification;
|
||||
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\NotificationLogService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* 코어 알림 로그 ↔ 비즈뿌리오 dispatch 연결 통합 테스트 (A-2 연결고리).
|
||||
*
|
||||
* Hook 도메인 규정(실제 훅 체인 관찰, mock 금지)에 따라, 코어 NotificationLogService 를 실제로
|
||||
* 호출해 core.notification_log.after_log_sent / after_log_failed 훅이 발화하고, 우리
|
||||
* LinkNotificationLogListener 가 발송 사이클 refkey 로 dispatch 에 코어 로그 id 를 연결하는지
|
||||
* 관찰한다(관찰 가능한 상태 변화 = dispatch.notification_log_id).
|
||||
*
|
||||
* 매칭은 복합키가 아니라 DispatchLinkContext 의 발송 사이클 refkey 직접 표식(A안)이다.
|
||||
*/
|
||||
class NotificationLogLinkTest extends PluginTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 테스트 환경에는 플러그인이 활성 등록되지 않으므로, A-2 연결 리스너를 HookManager 에
|
||||
// 명시 등록해 실제 훅 체인(after_log_sent/after_log_failed)이 이 리스너로 흐르게 한다.
|
||||
// getSubscribedHooks() 를 그대로 반영해 실제 발화 경로를 검증한다(리스너 직접 호출 아님).
|
||||
foreach (LinkNotificationLogListener::getSubscribedHooks() as $hook => $config) {
|
||||
HookManager::addAction(
|
||||
$hook,
|
||||
fn (...$args) => app(LinkNotificationLogListener::class)->{$config['method']}(...$args),
|
||||
$config['priority'] ?? 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 사이클을 흉내내 pending dispatch 를 만들고 refkey 를 컨텍스트에 남깁니다.
|
||||
*
|
||||
* 채널 드라이버 send() 의 dispatch 생성 + linkContext->remember() 와 동일한 상태를 만든다.
|
||||
*
|
||||
* @param string $channel 발송 채널
|
||||
* @return string 부여한 refkey
|
||||
*/
|
||||
private function beginDispatchCycle(string $channel = 'sms'): string
|
||||
{
|
||||
$refkey = 'rk_'.uniqid();
|
||||
|
||||
BizppurioDispatch::create([
|
||||
'refkey' => $refkey,
|
||||
'channel' => $channel,
|
||||
'to_number' => '01011112222',
|
||||
'content' => '본문',
|
||||
'notification_type' => 'welcome',
|
||||
'status' => 'pending',
|
||||
'source' => 'auto',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// 발송 사이클과 동일한 싱글턴 인스턴스에 refkey 를 남긴다.
|
||||
app(DispatchLinkContext::class)->remember($refkey);
|
||||
|
||||
return $refkey;
|
||||
}
|
||||
|
||||
public function test_발송_성공_로그가_dispatch에_연결된다(): void
|
||||
{
|
||||
$refkey = $this->beginDispatchCycle();
|
||||
|
||||
// 코어 로그 서비스로 실제 로그 생성 → after_log_sent 훅 발화 → 우리 리스너가 연결
|
||||
$log = app(NotificationLogService::class)->logSent([
|
||||
'channel' => 'sms',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => '01011112222',
|
||||
'recipient_name' => '홍길동',
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first();
|
||||
$this->assertNotNull($dispatch);
|
||||
$this->assertSame((int) $log->id, (int) $dispatch->notification_log_id);
|
||||
}
|
||||
|
||||
public function test_발송_실패_로그도_dispatch에_연결된다(): void
|
||||
{
|
||||
$refkey = $this->beginDispatchCycle('alimtalk');
|
||||
|
||||
$log = app(NotificationLogService::class)->logFailed([
|
||||
'channel' => 'alimtalk',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => '01011112222',
|
||||
'error_message' => 'boom',
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first();
|
||||
$this->assertSame((int) $log->id, (int) $dispatch->notification_log_id);
|
||||
}
|
||||
|
||||
public function test_비_비즈뿌리오_로그는_연결되지_않는다(): void
|
||||
{
|
||||
// remember() 를 부르지 않은 상태(mail/database 등 비-비즈뿌리오 발송 사이클)
|
||||
$log = app(NotificationLogService::class)->logSent([
|
||||
'channel' => 'mail',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => 'a@b.com',
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// 어떤 dispatch 도 이 로그에 연결되지 않아야 한다.
|
||||
$linked = BizppurioDispatch::query()->where('notification_log_id', $log->id)->exists();
|
||||
$this->assertFalse($linked);
|
||||
}
|
||||
|
||||
public function test_한_refkey는_한_로그에만_연결된다_consume_후_비워짐(): void
|
||||
{
|
||||
$refkey = $this->beginDispatchCycle();
|
||||
|
||||
// 첫 로그 → 연결됨
|
||||
$first = app(NotificationLogService::class)->logSent([
|
||||
'channel' => 'sms',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => '01011112222',
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
// 같은 사이클에서 refkey 를 다시 남기지 않은 채 둘째 로그 발생(다른 채널) → 연결 안 됨
|
||||
$second = app(NotificationLogService::class)->logSent([
|
||||
'channel' => 'database',
|
||||
'notification_type' => 'welcome',
|
||||
'extension_type' => 'core',
|
||||
'extension_identifier' => 'core',
|
||||
'recipient_identifier' => '01011112222',
|
||||
'source' => 'notification',
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
|
||||
$dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first();
|
||||
$this->assertSame((int) $first->id, (int) $dispatch->notification_log_id);
|
||||
$this->assertFalse(
|
||||
BizppurioDispatch::query()->where('notification_log_id', $second->id)->exists()
|
||||
);
|
||||
}
|
||||
}
|
||||
+60
@@ -72,4 +72,64 @@ class BizppurioDispatchRepositoryTest extends PluginTestCase
|
||||
$byNumber = $this->repo->paginate(['keyword' => '9999']);
|
||||
$this->assertSame(1, $byNumber->total());
|
||||
}
|
||||
|
||||
public function test_link_notification_log_by_refkey(): void
|
||||
{
|
||||
$this->makeDispatch(['refkey' => 'to_link']);
|
||||
|
||||
$linked = $this->repo->linkNotificationLog('to_link', 777);
|
||||
$this->assertTrue($linked);
|
||||
$this->assertSame(777, (int) $this->repo->findByRefkey('to_link')->notification_log_id);
|
||||
}
|
||||
|
||||
public function test_link_notification_log_missing_refkey_returns_false(): void
|
||||
{
|
||||
$this->assertFalse($this->repo->linkNotificationLog('nope', 1));
|
||||
}
|
||||
|
||||
public function test_find_by_notification_log_ids_keyed(): void
|
||||
{
|
||||
$this->makeDispatch(['refkey' => 'a', 'notification_log_id' => 10]);
|
||||
$this->makeDispatch(['refkey' => 'b', 'notification_log_id' => 20]);
|
||||
$this->makeDispatch(['refkey' => 'c', 'notification_log_id' => null]); // 미연결
|
||||
|
||||
$map = $this->repo->findByNotificationLogIdsKeyed([10, 20, 30]);
|
||||
|
||||
$this->assertCount(2, $map);
|
||||
$this->assertSame('a', $map[10]->refkey);
|
||||
$this->assertSame('b', $map[20]->refkey);
|
||||
$this->assertFalse($map->has(30));
|
||||
}
|
||||
|
||||
public function test_find_by_notification_log_ids_empty_returns_empty(): void
|
||||
{
|
||||
$this->assertTrue($this->repo->findByNotificationLogIdsKeyed([])->isEmpty());
|
||||
}
|
||||
|
||||
public function test_recent_linked_keyed_excludes_unlinked_and_keys_by_log_id(): void
|
||||
{
|
||||
$this->makeDispatch(['refkey' => 'x', 'notification_log_id' => 11]);
|
||||
$this->makeDispatch(['refkey' => 'y', 'notification_log_id' => 22]);
|
||||
$this->makeDispatch(['refkey' => 'z', 'notification_log_id' => null]); // 미연결 → 제외
|
||||
|
||||
$map = $this->repo->recentLinkedKeyed();
|
||||
|
||||
$this->assertCount(2, $map);
|
||||
$this->assertSame('x', $map[11]->refkey);
|
||||
$this->assertSame('y', $map[22]->refkey);
|
||||
}
|
||||
|
||||
public function test_recent_linked_keyed_respects_limit(): void
|
||||
{
|
||||
foreach (range(1, 5) as $i) {
|
||||
$this->makeDispatch(['refkey' => 'r'.$i, 'notification_log_id' => $i]);
|
||||
}
|
||||
|
||||
// 최신(id 큰) 것부터 limit 만큼 — 최근 2건만
|
||||
$map = $this->repo->recentLinkedKeyed(2);
|
||||
|
||||
$this->assertCount(2, $map);
|
||||
$this->assertTrue($map->has(5));
|
||||
$this->assertTrue($map->has(4));
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -16,6 +16,7 @@ use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
@@ -90,6 +91,7 @@ class AlimtalkChannelDriverTest extends PluginTestCase
|
||||
$this->fakeBindings($binding),
|
||||
$builder ?? $this->spyBuilder(),
|
||||
new BizppurioDispatchRepository,
|
||||
new DispatchLinkContext,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
|
||||
|
||||
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchResultService;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\ResultCodeResolver;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
|
||||
|
||||
/**
|
||||
* DispatchResultService — 코어 로그 id 배열 → 결과 표시 맵 변환 검증 (A-2 표시 주입).
|
||||
*
|
||||
* ResultCodeResolver 재사용으로 상태·`사유 (코드)`·잔액부족·대체발송을 파생하고, 매칭되지 않는
|
||||
* 로그 id 는 맵에서 빠지는지(빈 셀) 확인한다. 전화번호 등 민감정보는 결과에 포함하지 않는다.
|
||||
*/
|
||||
class DispatchResultServiceTest extends PluginTestCase
|
||||
{
|
||||
private DispatchResultService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new DispatchResultService(
|
||||
new BizppurioDispatchRepository,
|
||||
new ResultCodeResolver,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 로그 id 에 연결된 dispatch 1건을 만듭니다.
|
||||
*
|
||||
* @param array<string, mixed> $overrides dispatch 오버라이드
|
||||
*/
|
||||
private function linkedDispatch(int $logId, array $overrides = []): BizppurioDispatch
|
||||
{
|
||||
return BizppurioDispatch::create(array_merge([
|
||||
'refkey' => 'r'.uniqid(),
|
||||
'channel' => 'sms',
|
||||
'to_number' => '01011112222',
|
||||
'content' => 'x',
|
||||
'notification_type' => 'welcome',
|
||||
'notification_log_id' => $logId,
|
||||
'status' => 'success',
|
||||
'result_code' => '4100',
|
||||
'source' => 'auto',
|
||||
'sent_at' => now(),
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
public function test_성공_결과를_로그id_키_맵으로_반환한다(): void
|
||||
{
|
||||
$this->linkedDispatch(10);
|
||||
|
||||
$results = $this->service->resultsForLogIds([10]);
|
||||
|
||||
$this->assertArrayHasKey(10, $results);
|
||||
$this->assertSame('success', $results[10]['status']);
|
||||
$this->assertSame('4100', $results[10]['result_code']);
|
||||
$this->assertFalse($results[10]['is_low_balance']);
|
||||
// 민감정보(전화번호)는 결과에 포함하지 않는다.
|
||||
$this->assertArrayNotHasKey('to_number', $results[10]);
|
||||
}
|
||||
|
||||
public function test_잔액부족_코드는_is_low_balance_true다(): void
|
||||
{
|
||||
$this->linkedDispatch(20, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']);
|
||||
|
||||
$results = $this->service->resultsForLogIds([20]);
|
||||
|
||||
$this->assertTrue($results[20]['is_low_balance']);
|
||||
}
|
||||
|
||||
public function test_대체발송_상태가_결과에_포함된다(): void
|
||||
{
|
||||
$this->linkedDispatch(30, ['fallback_status' => '성공']);
|
||||
|
||||
$results = $this->service->resultsForLogIds([30]);
|
||||
|
||||
$this->assertSame('성공', $results[30]['fallback_status']);
|
||||
}
|
||||
|
||||
public function test_result_code_없으면_result_label_null이다(): void
|
||||
{
|
||||
$this->linkedDispatch(40, ['status' => 'sent', 'result_code' => null]);
|
||||
|
||||
$results = $this->service->resultsForLogIds([40]);
|
||||
|
||||
$this->assertNull($results[40]['result_label']);
|
||||
$this->assertNotNull($results[40]['status_label']); // 상태 라벨은 있음(발송중)
|
||||
}
|
||||
|
||||
public function test_매칭되지_않는_로그id는_맵에서_빠진다(): void
|
||||
{
|
||||
$this->linkedDispatch(50);
|
||||
|
||||
$results = $this->service->resultsForLogIds([50, 999]);
|
||||
|
||||
$this->assertArrayHasKey(50, $results);
|
||||
$this->assertArrayNotHasKey(999, $results);
|
||||
}
|
||||
|
||||
public function test_recent_results_는_연결된_최근_결과를_로그id_키_맵으로_반환한다(): void
|
||||
{
|
||||
$this->linkedDispatch(60, ['result_code' => '4100']);
|
||||
$this->linkedDispatch(61, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']);
|
||||
|
||||
$results = $this->service->recentResults();
|
||||
|
||||
$this->assertSame('success', $results[60]['status']);
|
||||
$this->assertTrue($results[61]['is_low_balance']);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -10,9 +10,10 @@ 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\Models\BizppurioDispatch;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
|
||||
use Plugins\Sirsoft\MessageBizppurio\Services\SmsTypeResolver;
|
||||
@@ -59,6 +60,7 @@ class SmsChannelDriverTest extends PluginTestCase
|
||||
new SmsTypeResolver,
|
||||
$builder ?? $this->spyBuilder(),
|
||||
new BizppurioDispatchRepository,
|
||||
new DispatchLinkContext,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# audit:allow test-scenario-coverage reason: |
|
||||
# A-2 는 코어 알림 발송 이력 화면에 비즈뿌리오 결과(도달·실패사유·잔액부족·대체발송)를 얹는
|
||||
# 작업이다(코어 앱·코어 테이블 무수정). axes cross product 의 런타임 효과는 계층별로 전수 가드된다:
|
||||
# - 연결고리(코어 로그↔dispatch): NotificationLogLinkTest — 실제 훅 체인(after_log_sent/
|
||||
# after_log_failed)으로 dispatch.notification_log_id 연결, 비-비즈뿌리오 로그 미연결, refkey
|
||||
# consume 후 비워짐(오매칭 방지)까지 관찰.
|
||||
# - 링크·배치조회 Repository: BizppurioDispatchRepositoryTest — link/미존재/keyed/빈배열.
|
||||
# - 결과 표시 파생(상태·사유(코드)·잔액부족·대체발송·미매칭 제외): DispatchResultServiceTest.
|
||||
# - 결과 조회 API(권한·검증·성공/실패/잔액부족/미매칭): DispatchResultEndpointTest.
|
||||
# - overlay 결과 컬럼 실제 렌더(성공/잔액부족/대체발송/빈셀 조건부 렌더): notification_log_result.test.tsx.
|
||||
# e2e_* 축(브라우저 왕복: 코어 이력 화면 진입→결과 컬럼 확인→회원 링크/배지)은 실제 발송·webhook
|
||||
# 왕복 결과가 필요해, 계획서 §9 방침대로 PO 직접 수행하는 실연동 검증 단계에서 Playwright spec 을
|
||||
# 그때 @scenario/@effects 로 이 매니페스트에 매핑한다. Vitest 렌더 테스트가 프론트 overlay 컬럼
|
||||
# 주입·조건부 렌더를 가드하므로 이번 단계는 spec 미도입
|
||||
# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 렌더 커버로 충족).
|
||||
|
||||
feature: 비즈뿌리오 발송 결과를 코어 알림 발송 이력 화면에 주입 (A-2)
|
||||
|
||||
description: |
|
||||
비즈뿌리오로 보낸 문자·알림톡의 실제 결과(성공/실패·사유·잔액부족·대체발송)를 코어 "알림 발송
|
||||
이력" 화면의 각 행에 붙여 한 화면에서 본다. 독립 페이지·메뉴 없음. 코어 앱·코어 테이블 무수정 —
|
||||
연결 표식은 비즈뿌리오 쪽(bizppurio_dispatches.notification_log_id)에만 두고, layout_extensions
|
||||
overlay 로 결과 컬럼을 코어 이력 datagrid 에 _append 한다.
|
||||
|
||||
핵심 동작:
|
||||
- 연결고리: 채널 드라이버가 발송 사이클에서 dispatch(refkey) 생성 후 DispatchLinkContext 에
|
||||
refkey 를 남기고, 직후 발화하는 코어 after_log_sent/after_log_failed 훅에서
|
||||
LinkNotificationLogListener 가 그 refkey 로 dispatch 에 코어 로그 id 를 기록한다(직접 표식,
|
||||
복합키 근사 매칭 아님 — 동일 시각 중복 발송 오매칭 방지).
|
||||
- 결과 조회 API: 코어 이력 화면이 현재 페이지 로그 id 배열을 POST 로 넘기면(N+1 회피) 결과 맵
|
||||
반환. 상태·`사유 (코드)`(ResultCodeResolver 재사용)·잔액부족 여부·대체발송 상태. 전화번호 등
|
||||
민감정보 미포함(결과 컬럼엔 노출 안 함).
|
||||
- overlay: 코어 datagrid columns 에 결과 컬럼 1개 _append. row.id 로 결과 맵 매칭. 매칭 안 되는
|
||||
행(메일·DB 등 비-비즈뿌리오)은 빈 셀(-).
|
||||
|
||||
axes:
|
||||
channel: [sms, lms, alimtalk]
|
||||
result_category: [success, failure, balance_low]
|
||||
fallback: [none, present]
|
||||
recipient_kind: [member, guest]
|
||||
link_outcome: [linked, unmatched]
|
||||
e2e_view_action: [open_history, view_result_cell, open_member]
|
||||
|
||||
exclusions:
|
||||
- { link_outcome: unmatched, result_category: success, reason: "미매칭(비-비즈뿌리오) 행은 결과 자체가 없어 result_category 조합 무의미 — 빈 셀 한 케이스로 충분" }
|
||||
- { link_outcome: unmatched, fallback: present, reason: "미매칭 행은 대체발송 결과도 없음" }
|
||||
- { recipient_kind: guest, e2e_view_action: open_member, reason: "비회원은 회원 상세 링크 없음(일반 텍스트) — 회원 축과 무관" }
|
||||
|
||||
effects:
|
||||
# 연결고리 (백엔드 — 실제 훅 체인)
|
||||
- sent_log_links_dispatch_via_refkey_in_same_cycle
|
||||
- failed_log_links_dispatch_via_refkey_in_same_cycle
|
||||
- non_bizppurio_log_leaves_dispatch_unlinked
|
||||
- refkey_consumed_once_prevents_cross_cycle_mismatch
|
||||
# 링크·배치조회 Repository (백엔드)
|
||||
- link_notification_log_by_refkey_sets_log_id
|
||||
- link_missing_refkey_returns_false
|
||||
- find_by_log_ids_returns_keyed_map_excluding_unmatched
|
||||
# 결과 표시 파생 (백엔드)
|
||||
- success_result_derives_status_and_reason_code_label
|
||||
- balance_low_result_flags_is_low_balance_true
|
||||
- fallback_status_surfaced_in_result
|
||||
- missing_result_code_yields_null_result_label_with_status_label
|
||||
- unmatched_log_id_excluded_from_result_map
|
||||
- result_map_excludes_recipient_phone_masking_not_needed
|
||||
# 결과 조회 API (백엔드)
|
||||
- lookup_requires_messaging_view_permission
|
||||
- lookup_requires_log_ids_array
|
||||
- lookup_returns_results_keyed_by_log_id
|
||||
# overlay 결과 컬럼 렌더 (프론트)
|
||||
- overlay_appends_result_column_to_core_history_datagrid
|
||||
- result_column_batch_fetches_by_current_page_log_ids
|
||||
- success_cell_renders_reason_code_label
|
||||
- balance_low_cell_renders_low_balance_badge
|
||||
- non_low_balance_cell_omits_low_balance_badge
|
||||
- fallback_cell_renders_fallback_badge
|
||||
- unmatched_row_renders_empty_cell
|
||||
# e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상)
|
||||
- e2e_open_history_shows_result_column
|
||||
- e2e_result_cell_shows_status_reason_and_badges
|
||||
- e2e_member_recipient_links_to_user_detail
|
||||
|
||||
test_files:
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php
|
||||
- plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx
|
||||
|
||||
audit:
|
||||
status: codified
|
||||
@@ -6,11 +6,12 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
include: ['resources/js/__tests__/**/*.test.{ts,tsx}'],
|
||||
setupFiles: [],
|
||||
setupFiles: ['./resources/js/__tests__/setup.ts'],
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'resources/js'),
|
||||
'@core': resolve(__dirname, '../../../resources/js/core'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user