From 11f1d79540c55db72ffc0531fb1d06448374efbf Mon Sep 17 00:00:00 2001 From: chym1217 Date: Mon, 20 Jul 2026 16:20:07 +0900 Subject: [PATCH] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=BD=94=EC=96=B4?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EC=9D=B4=EB=A0=A5?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=AC=B8=EC=9E=90=C2=B7?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=ED=86=A1=20=EA=B2=B0=EA=B3=BC=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 발송 결과(성공/실패·사유·잔액부족·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 레퍼런스 동반. --- docs/backend/api/README.md | 4 +- .../sirsoft-message_bizppurio/CHANGELOG.md | 3 +- ...0001_create_bizppurio_dispatches_table.php | 2 + .../seeders/DispatchResultDemoSeeder.php | 184 ++++++++++ .../docs/api/README.md | 3 +- .../docs/api/dispatch-results.md | 328 ++++++++++++++++++ .../docs/api/notification-bindings.md | 2 - .../sirsoft-message_bizppurio/plugin.php | 3 + .../extensions/notification_log_result.json | 175 ++++++++++ .../layouts/notification_log_result.test.tsx | 190 ++++++++++ .../resources/js/__tests__/setup.ts | 25 ++ .../resources/lang/en.json | 12 + .../resources/lang/ko.json | 12 + .../Admin/DispatchResultController.php | 61 ++++ .../Requests/DispatchResultLookupRequest.php | 52 +++ .../Requests/StoreAlimtalkTemplateRequest.php | 2 + .../StoreNotificationBindingRequest.php | 2 + .../Requests/UploadTemplateImageRequest.php | 2 + .../Listeners/LinkNotificationLogListener.php | 90 +++++ .../src/Models/BizppurioDispatch.php | 12 +- .../MessageBizppurioServiceProvider.php | 15 + .../BizppurioDispatchRepository.php | 56 +++ .../BizppurioDispatchRepositoryInterface.php | 37 ++ .../src/Services/AlimtalkChannelDriver.php | 6 + .../src/Services/DispatchLinkContext.php | 55 +++ .../src/Services/DispatchResultService.php | 97 ++++++ .../src/Services/SmsChannelDriver.php | 6 + .../src/routes/api.php | 21 ++ .../DispatchResultEndpointTest.php | 228 ++++++++++++ .../Notification/NotificationLogLinkTest.php | 159 +++++++++ .../BizppurioDispatchRepositoryTest.php | 60 ++++ .../Services/AlimtalkChannelDriverTest.php | 2 + .../Services/DispatchResultServiceTest.php | 113 ++++++ .../Unit/Services/SmsChannelDriverTest.php | 4 +- .../scenarios/dispatch-result-injection.yaml | 91 +++++ .../vitest.config.ts | 3 +- 36 files changed, 2105 insertions(+), 12 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index 504fdee8..28077d63 100644 --- a/docs/backend/api/README.md +++ b/docs/backend/api/README.md @@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; } > 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다. -- **확장 수**: 10 · **엔드포인트 수**: 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 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md index aa2082ce..0ea37664 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md @@ -16,7 +16,8 @@ - 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다. - 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다. - 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.) -- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.) +- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). +- 관리자 "알림 발송 이력" 화면에서 문자·알림톡의 발송 결과(성공/실패·사유·지갑 잔액 부족·SMS 대체발송 여부)를 각 발송 행에서 바로 확인할 수 있습니다. 문자·알림톡 탭에서만 결과가 표시되며, 행을 펼치면 상세 결과를 볼 수 있습니다. - 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다. - 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다. - 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php index b9bfb832..fd029852 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php @@ -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') { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php b/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php new file mode 100644 index 00000000..0d47269b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/database/seeders/DispatchResultDemoSeeder.php @@ -0,0 +1,184 @@ +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> + */ + 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> + */ + 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 $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 $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).'건을 정리했습니다.'); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index cf935df5..e6cf7927 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md @@ -4,11 +4,12 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 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 | diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md new file mode 100644 index 00000000..530e1c45 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md @@ -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 + +- **라우트명**: `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 | `[]` | | + +**응답 예시** + +```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` 에 필드별 메시지) | + + + +**설명** + +코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(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 + +- **라우트명**: `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…` | | + +**응답 예시** + +```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`)이 없는 경우 | + + + +**설명** + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md index f294271e..253fb224 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -2,8 +2,6 @@ > **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. -> **용도**: 코어 알림 편집 모달(alimtalk 채널)에 얹힌 전용 칸이 소비하는 조회·저장 API. 연결 템플릿 드롭다운/SMS 대체 토글을 바꾸면 즉시 `store` 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관, 코어 템플릿 무오염). 저장은 "빈 코드=해제" 규칙을 따른다. - --- ## TL;DR (5초 요약) diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 492b4b0d..16ec4181 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -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 @@ -339,6 +341,7 @@ class Plugin extends AbstractPlugin RegisterNotificationChannelsListener::class, SeedChannelTemplatesListener::class, GuestPhoneExtractListener::class, + LinkNotificationLogListener::class, ValidateBizppurioSettingsListener::class, ]; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json new file mode 100644 index 00000000..531ad00e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json @@ -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 +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx new file mode 100644 index 00000000..d4d196ce --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx @@ -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(); + }); +}); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts new file mode 100644 index 00000000..5eeecb61 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts @@ -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(), + })), + }); +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index a1d65e73..414c1c6e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -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.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json index 42137dc1..4ddcb2d5 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -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": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php new file mode 100644 index 00000000..c3b4f21e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php @@ -0,0 +1,61 @@ + $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(), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php new file mode 100644 index 00000000..cfaba4a9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php @@ -0,0 +1,52 @@ + + */ + public function rules(): array + { + return [ + // 빈 배열 허용: 코어 알림 발송 이력이 0건(또는 로드 전)이면 화면이 빈 배열을 보낸다. + // 이때 422 로 막으면 결과 컬럼 조회 자체가 에러가 되므로, present(키 존재)만 요구하고 + // 빈 배열은 통과시켜 빈 결과 맵을 돌려준다. + 'notification_log_ids' => ['present', 'array', 'max:100'], + 'notification_log_ids.*' => ['integer', 'min:1'], + ]; + } + + /** + * 검증된 코어 알림 로그 id 목록을 정수 배열로 반환합니다. + * + * @return array 로그 id 목록 + */ + public function logIds(): array + { + return array_map('intval', $this->validated('notification_log_ids', [])); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php index fe3e6b36..60337d20 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreAlimtalkTemplateRequest.php @@ -25,6 +25,8 @@ class StoreAlimtalkTemplateRequest extends FormRequest { /** * 권한 검사는 라우트 미들웨어가 담당 — 항상 통과. + * + * @return bool */ public function authorize(): bool { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php index 4a7f9e9d..3ca1a11c 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php @@ -16,6 +16,8 @@ class StoreNotificationBindingRequest extends FormRequest { /** * 권한은 라우트 미들웨어(messaging.manage)에서 처리한다. + * + * @return bool */ public function authorize(): bool { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php index 4f5b031e..e663bb5e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UploadTemplateImageRequest.php @@ -18,6 +18,8 @@ class UploadTemplateImageRequest extends FormRequest { /** * 권한 검사는 라우트 미들웨어가 담당 — 항상 통과. + * + * @return bool */ public function authorize(): bool { diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php new file mode 100644 index 00000000..0f0f966b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php @@ -0,0 +1,90 @@ +> + */ + 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(), + ]); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php index 2a8c5903..fbb57f22 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php @@ -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', diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 774cedf1..d75e915e 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -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 디렉토리를 로드합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php index 8f52d1af..35e20d58 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php @@ -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 $notificationLogIds 코어 로그 id 목록 + * @return Collection 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 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'); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php index c4ed0052..6e6935da 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php @@ -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 */ 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 $notificationLogIds 코어 로그 id 목록 + * @return Collection 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 notification_log_id 를 키로 하는 dispatch 맵 + */ + public function recentLinkedKeyed(int $limit = 1000): Collection; } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php index 4b8f5bf9..94847966 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -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); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php new file mode 100644 index 00000000..42fe4fc5 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php @@ -0,0 +1,55 @@ +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; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php new file mode 100644 index 00000000..d21b49cd --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php @@ -0,0 +1,97 @@ + $notificationLogIds 코어 로그 id 목록(현재 페이지) + * @return array> 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> notification_log_id → 결과 표시 데이터 + */ + public function recentResults(int $limit = 1000): array + { + return $this->buildResultMap($this->dispatches->recentLinkedKeyed($limit)); + } + + /** + * dispatch 맵을 log id 키의 결과 표시 배열 맵으로 변환합니다. + * + * @param \Illuminate\Support\Collection $dispatchMap log id 키 dispatch 맵 + * @return array> 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 상태·결과 라벨·잔액부족·대체발송 표시 데이터 + */ + 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, + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php index d9e3856e..f9a953dc 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php @@ -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); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index 30d29971..e92010a0 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php @@ -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'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php new file mode 100644 index 00000000..9cf9527b --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php @@ -0,0 +1,228 @@ + $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 $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); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php new file mode 100644 index 00000000..626e5752 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php @@ -0,0 +1,159 @@ + $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() + ); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php index a9c8ea35..00ee566a 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php @@ -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)); + } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php index e45adb08..153934a8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -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, ); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php new file mode 100644 index 00000000..d9f3d269 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php @@ -0,0 +1,113 @@ +service = new DispatchResultService( + new BizppurioDispatchRepository, + new ResultCodeResolver, + ); + } + + /** + * 코어 로그 id 에 연결된 dispatch 1건을 만듭니다. + * + * @param array $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']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php index 0028ede4..bc222347 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php @@ -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, ); } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml new file mode 100644 index 00000000..72b15fe0 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml @@ -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 diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts index ffd85611..ae686dc2 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts +++ b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts @@ -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'), }, }, });