From af43a7c41d3cba1733662dfb025c41eb89f1c3d3 Mon Sep 17 00:00:00 2001 From: chym1217 Date: Wed, 15 Jul 2026 17:50:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=ED=86=A1=20=EC=97=B0=EB=8F=99=20=ED=8E=B8=EC=A7=91=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20=EC=A0=84=EC=9A=A9=EC=B9=B8=20=EC=A6=89=EC=8B=9C?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20+=20=EC=8B=9C=EB=94=A9=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=20=ED=9B=85=20template=20=EC=A6=9D=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 재설계 — 코어 알림 설정 화면을 그대로 쓰고 알림톡 편집 시 전용 칸만 얹는다. - 시딩 필터 훅(seed.*.notifications.translations) 편승으로 회원 알림에 sms·alimtalk template 증강 (코어·모듈 무수정, cleanupStaleTemplates 에도 유지) - 코어 편집 모달에 overlay 로 전용 칸 append(연결 템플릿·SMS 대체) — 변경 즉시 저장, 코어 저장 버튼과 무관 (코어 템플릿 무오염) - 알림톡·SMS 탭 상태 배너(설정 미완료/테스트 모드) - 1차 구현(우리 목록 통째 교체·게이트·우리 편집 모달) 폐기 전 계층 테스트 통과 (백엔드 170 / 프론트 63), audit 위반 0. --- docs/backend/api/README.md | 4 +- .../docs/api/README.md | 3 +- .../docs/api/alimtalk-templates.md | 41 -- .../docs/api/notification-bindings.md | 197 +++++++++ .../lang/en/messages.php | 6 + .../lang/ko/messages.php | 6 + .../sirsoft-message_bizppurio/plugin.php | 40 +- .../extensions/notification_tab_core.json | 373 ++++++++++++++++++ .../layouts/notification_tab_binding.test.tsx | 142 +++++++ .../resources/lang/en.json | 16 + .../resources/lang/ko.json | 16 + .../src/Concerns/GuardsKakaoRequests.php | 42 ++ .../Admin/AlimtalkTemplateController.php | 36 +- .../Admin/NotificationBindingController.php | 95 +++++ .../StoreNotificationBindingRequest.php | 39 ++ .../RegisterNotificationChannelsListener.php | 24 +- .../SeedChannelTemplatesListener.php | 260 ++++++++++++ .../MessageBizppurioServiceProvider.php | 22 +- .../src/Services/AlimtalkChannelDriver.php | 217 ++++++++++ .../Services/NotificationBindingService.php | 151 +++++++ .../src/routes/api.php | 24 ++ .../NotificationBindingEndpointTest.php | 191 +++++++++ ...gisterNotificationChannelsListenerTest.php | 36 +- .../SeedChannelTemplatesListenerTest.php | 149 +++++++ .../Services/AlimtalkChannelDriverTest.php | 306 ++++++++++++++ .../NotificationBindingServiceTest.php | 144 +++++++ .../scenarios/alimtalk-dispatch-binding.yaml | 101 +++++ 27 files changed, 2596 insertions(+), 85 deletions(-) create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md index db6fff1f..504fdee8 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 · **엔드포인트 수**: 388 +- **확장 수**: 10 · **엔드포인트 수**: 391 | 확장 | 유형 | 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) | 3 / 16 | +| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 4 / 19 | | `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/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md index 3b65cd51..cf935df5 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 @@ > 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다. -- **문서 수**: 3 · **엔드포인트 수**: 16 +- **문서 수**: 4 · **엔드포인트 수**: 19 | 문서 | 도메인 | 엔드포인트 | | --- | --- | --- | | [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 | +| [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/alimtalk-templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md index d79b3de5..a1b2e664 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md @@ -16,43 +16,6 @@ --- -## 개요 - -설정 페이지 알림톡 템플릿 탭에서 사용하는 관리 API 입니다. 카카오 관리 API(`kapi.ppurio.com`)에 -실시간 위임하며 템플릿을 DB 에 저장하지 않습니다. 조회 계열은 `messaging.view`, 변경 계열(등록· -수정·삭제·검수·상태변경·이미지 업로드)은 `messaging.manage` 권한을 요구합니다. kapi 실패(반려·차단· -상태오류)는 `422` 로 전파되며 `errors.result_code`(kapi 결과코드)·`errors.kakao_message`(카카오 사유)를 -함께 내려줍니다. - -**주요 요청 파라미터(등록·수정 `POST /` · `PUT /{code}`)** - -| 파라미터 | 타입 | 필수 | 설명 | -| --- | --- | :---: | --- | -| `templateName` | string | ✓ | 템플릿명(관리용, 최대 200자) | -| `templateContent` | string | ✓ | 본문(최대 1300자, `#{변수}` 사용) | -| `categoryCode` | string | ✓ | 카테고리 코드(카테고리 조회에서 획득) | -| `templateEmphasizeType` | string | ✓ | 유형: `NONE`/`IMAGE`/`TEXT`(1차 지원, ITEM_LIST 제외) | -| `templateCode` | string | | 템플릿 코드. 비우면 카카오가 **자동 생성** | -| `templateTitle`/`templateSubtitle` | string | △ | 강조표기형(TEXT) 필수 | -| `templateImageUrl`/`templateImageName` | string | △ | 이미지형(IMAGE) 필수 — 이미지 업로드 API 로 획득한 URL | -| `templateExtra`·`templatePreviewMessage`·`securityFlag` | mixed | | 선택: 부가정보·미리보기 메시지·보안 | -| `buttons[]`(최대5)·`quickReplies[]`(최대10)·`templateRepresentLink` | array | | 선택: 버튼·바로연결·대표 링크 | -| `requestInspection` | boolean | | true 면 등록 직후 검수요청까지 수행([등록 후 검수요청]) | - -> `senderKey`·`templateMessageType`(BA/EX/AD/MI)은 클라이언트가 보내지 않습니다. `senderKey` 는 -> 환경설정(발신프로필 키), `templateMessageType` 은 부가정보·채널추가(AC) 버튼 유무로 서버가 자동 계산합니다. - -**이미지 업로드(`POST /image`, multipart)**: 이미지형 템플릿용 이미지를 카카오 서버에 올려 URL 을 -반환합니다(`data.image_url`). 제한: jpg/png, 최대 500KB, 가로 500px 이상. - -상태별 가능 액션(정본): REG→수정/삭제/검수요청 · REQ→검수취소 · REJ→수정/삭제 · -RDY·ACT→중지/승인취소 · STP→중지해제 · DMT→휴면해제 · BLK→(없음). - -아래 엔드포인트별 상세는 `php artisan api:docgen` 이 생성한 스캐폴딩입니다. 실측이 인증을 요구해 -제외된 응답 필드/예시는 실연동 검증 단계에서 사람이 보강합니다. - ---- - ### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates @@ -114,7 +77,6 @@ Authorization: Bearer {YOUR_TOKEN} | templateImageUrl | body | string | 아니오 | max 500 | | | templatePreviewMessage | body | string | 아니오 | max 40 | | | templateExtra | body | string | 아니오 | max 500 | | -| templateHeader | body | string | 아니오 | max 16 | | | securityFlag | body | boolean | 아니오 | — | | | requestInspection | body | boolean | 아니오 | — | | | buttons | body | array | 아니오 | max 5 | | @@ -142,7 +104,6 @@ Content-Type: application/json "templateImageUrl": "https://example.com", "templatePreviewMessage": "예시값", "templateExtra": "예시값", - "templateHeader": "예시값", "securityFlag": true, "requestInspection": true, "buttons": [ @@ -411,7 +372,6 @@ Authorization: Bearer {YOUR_TOKEN} | templateImageUrl | body | string | 아니오 | max 500 | | | templatePreviewMessage | body | string | 아니오 | max 40 | | | templateExtra | body | string | 아니오 | max 500 | | -| templateHeader | body | string | 아니오 | max 16 | | | securityFlag | body | boolean | 아니오 | — | | | requestInspection | body | boolean | 아니오 | — | | | buttons | body | array | 아니오 | max 5 | | @@ -439,7 +399,6 @@ Content-Type: application/json "templateImageUrl": "https://example.com", "templatePreviewMessage": "예시값", "templateExtra": "예시값", - "templateHeader": "예시값", "securityFlag": true, "requestInspection": true, "buttons": [ diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md new file mode 100644 index 00000000..f294271e --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md @@ -0,0 +1,197 @@ +# Notification Bindings API 레퍼런스 + +> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다. + +> **용도**: 코어 알림 편집 모달(alimtalk 채널)에 얹힌 전용 칸이 소비하는 조회·저장 API. 연결 템플릿 드롭다운/SMS 대체 토글을 바꾸면 즉시 `store` 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관, 코어 템플릿 무오염). 저장은 "빈 코드=해제" 규칙을 따른다. + +--- + +## TL;DR (5초 요약) + +```text +1. 이 문서는 실제 API 호출로 실측한 Notification Bindings 엔드포인트 레퍼런스입니다 +2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope) +3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다 +4. 갱신: 코드 변경 후 php artisan api:docgen 재실행 +5. 설명(TODO) 칸은 사람이 채웁니다 +``` + +--- + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@index` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| bindings | array | `[]` | | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.success", + "data": { + "bindings": [] + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +알림 설정 화면의 알림톡 탭이 소비하는 목록이다. 코어 알림 정의 중 채널에 `alimtalk` 을 포함하는 활성 알림 전체를, 연결된 알림톡 템플릿(binding)과 조인해 반환한다. 미연결 알림은 `is_bound=false` 이고 `template_code`/`template_name` 은 null, `fallback_sms_enabled` 는 false 다. `variables` 는 코어 알림 정의의 변수 목록으로, 편집 모달의 "제공 변수" 안내에 쓰인다. + + +### POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@store` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage` + +**요청 파라미터** + +| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | +| --- | --- | --- | --- | --- | --- | +| notification_type | body | string | 예 | max 100 | | +| template_code | body | string | 아니오 | max 30 | | +| template_name | body | string | 아니오 | max 255 | template 이름 (식별자) | +| fallback_sms_enabled | body | boolean | 아니오 | — | | + +**요청 예시** + +```http +POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +Content-Type: application/json + +{ + "notification_type": "예시값", + "template_code": "예시값", + "template_name": "예시 이름", + "fallback_sms_enabled": true +} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| bindings | object | `{"실측 예시값":{"notification_type":"실측 예시값","template_code":"…` | | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "messages.binding.saved", + "data": { + "bindings": { + "실측 예시값": { + "notification_type": "실측 예시값", + "template_code": "실측 예시값", + "template_name": "실측 예시값", + "fallback_sms_enabled": true + } + } + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 | +| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) | + + + +**설명** + +알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 승인되지 않은 템플릿 코드도 저장 자체는 가능하나(운영자 선택 자유), 편집 모달 드롭다운은 승인(RDY/ACT) 템플릿만 노출해 실수를 방지한다. + + +### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates + +- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates` +- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@approvedTemplates` +- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + + + +**응답 예시** + + + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 | + + + +**설명** + +연동 편집 모달의 "연결 템플릿" 드롭다운을 채우는 소스다. 카카오 템플릿 목록 중 serviceStatus 가 RDY(발송전)·ACT(정상)인 승인 템플릿만 반환한다. 자격증명(bizId·apiKey·senderKey) 미설정이거나 kapi 조회 실패 시 422 로 카카오 사유를 그대로 전달한다. + + diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php index ee4a74aa..e64e04d4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php @@ -124,4 +124,10 @@ return [ 'image_url' => 'Image URL', ], ], + + // Notification-to-alimtalk template binding (NotificationBindingController responses) + 'binding' => [ + 'saved' => 'Alimtalk binding saved.', + 'removed' => 'Alimtalk binding removed.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php index aa2079a6..bc1bcdf6 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php @@ -124,4 +124,10 @@ return [ 'image_url' => '이미지 링크', ], ], + + // 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답) + 'binding' => [ + 'saved' => '알림톡 연동을 저장했습니다.', + 'removed' => '알림톡 연동을 해제했습니다.', + ], ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php index 762108a1..492b4b0d 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php @@ -5,8 +5,12 @@ namespace Plugins\Sirsoft\MessageBizppurio; use App\Enums\ExtensionOwnerType; use App\Extension\AbstractPlugin; use App\Extension\Helpers\ExtensionMenuSyncHelper; +use App\Extension\ModuleManager; +use Database\Seeders\NotificationDefinitionSeeder; +use Illuminate\Support\Facades\Log; use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener; +use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener; use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener; /** @@ -33,6 +37,34 @@ class Plugin extends AbstractPlugin ]; } + /** + * 플러그인 활성화 — 기존 회원 알림에 sms·alimtalk template 을 즉시 증강. + * + * SeedChannelTemplatesListener 는 코어/모듈이 알림 정의를 *시딩할 때* 필터 훅으로 증강한다. + * 그러나 플러그인 활성화 시점에는 코어/모듈 정의가 이미 시딩돼 있어(우리 채널 없이) 다음 + * 재시딩까지 alimtalk template 이 생기지 않는다. 따라서 활성화 시 코어·활성 모듈의 알림 + * 정의를 재시드해, 이제 활성화된 우리 필터를 통과시켜 기존 정의에도 채널을 즉시 반영한다. + * + * 재시드는 모두 user_overrides 보존 upsert(멱등)이며 코어·모듈 파일을 수정하지 않는다. + * 실패는 로그만 남기고 활성화 자체는 막지 않는다(발송·연동은 채널 등록만으로도 동작하며, + * template 은 다음 정상 재시딩에도 수렴). + * + * @return bool 활성화 성공 여부 + */ + public function activate(): bool + { + try { + app(NotificationDefinitionSeeder::class)->run(); + app(ModuleManager::class)->resyncAllActiveDeclarativeArtifacts(); + } catch (\Throwable $e) { + Log::warning('[sirsoft-message_bizppurio] 알림 채널 template 증강 재시드 실패', [ + 'error' => $e->getMessage(), + ]); + } + + return true; + } + /** * 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리. * @@ -294,9 +326,10 @@ class Plugin extends AbstractPlugin /** * 훅 리스너 목록 반환 * - * Phase 3: 채널 등록/readiness/3영역 노출(RegisterNotificationChannelsListener) + - * 비회원 주문 전화번호 주입(GuestPhoneExtractListener). - * Phase 4~6 에서 webhook/알림톡 발송 리스너를 추가한다. + * - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3) + * - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅) + * - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3) + * - ValidateBizppurioSettingsListener: 환경설정 검증 * * @return array */ @@ -304,6 +337,7 @@ class Plugin extends AbstractPlugin { return [ RegisterNotificationChannelsListener::class, + SeedChannelTemplatesListener::class, GuestPhoneExtractListener::class, ValidateBizppurioSettingsListener::class, ]; diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json new file mode 100644 index 00000000..c8ced647 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json @@ -0,0 +1,373 @@ +{ + "target_layout": "admin_settings", + "comment": "알림 템플릿 편집 모달(alimtalk 채널)에 비즈뿌리오 전용 칸을 append 한다(계획서 §6-2, Phase 6). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다(코어 템플릿 무오염). channel==='alimtalk'일 때만 ①안내 카드 ②연결 템플릿 드롭다운 ③SMS 대체 토글을 노출하며, 값을 바꾸면 즉시 우리 API 로 저장하고 toast 로 피드백한다(별도 저장 버튼 없음 — PO 확정 UX). 코어 [저장] 버튼은 본문·수신자만 저장하고 연동과 무관하다. 초기값·저장 후 상태는 bizppurioBindings 데이터소스에서 편집 대상 알림 type 으로 읽는다.", + "data_sources": [ + { + "id": "bizppurioBindings", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "fallback": { + "data": { + "bindings": {} + } + } + }, + { + "id": "bizppurioApprovedTemplates", + "type": "api", + "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates", + "method": "GET", + "auto_fetch": true, + "auth_required": true, + "fallback": { + "data": { + "templates": [] + } + } + } + ], + "injections": [ + { + "target_id": "notif_channel_content", + "position": "prepend_child", + "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음, PO Q1-A). readiness 미충족 🔴 설정하기 안내 / is_test_mode 🟡 테스트 모드 안내. 코어 목록·모달은 그대로.", + "components": [ + { + "id": "bizppurio_status_banner", + "type": "basic", + "name": "Div", + "if": "{{['sms','alimtalk'].includes(_local.activeNotificationChannel ?? query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true)}}", + "props": { + "className": "mb-3" + }, + "children": [ + { + "id": "bizppurio_banner_not_ready", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready === false}}", + "props": { + "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-exclamation", + "size": "sm", + "className": "text-red-600 dark:text-red-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-red-800 dark:text-red-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.not_ready" + } + ] + }, + { + "type": "basic", + "name": "Button", + "props": { + "type": "button", + "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap" + }, + "text": "$t:sirsoft-message_bizppurio.banner.setup_action", + "actions": [ + { + "type": "click", + "handler": "navigate", + "params": { + "path": "/admin/plugins/sirsoft-message_bizppurio/settings" + } + } + ] + } + ] + }, + { + "id": "bizppurio_banner_test_mode", + "type": "basic", + "name": "Div", + "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.readiness?.ready !== false && (availableChannels?.data?.channels ?? []).find(c => c.id === (_local.activeNotificationChannel ?? query.channel))?.is_test_mode === true}}", + "props": { + "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-flask", + "size": "sm", + "className": "text-amber-600 dark:text-amber-400" + } + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-sm text-amber-800 dark:text-amber-200" + }, + "text": "$t:sirsoft-message_bizppurio.banner.test_mode" + } + ] + } + ] + } + ] + }, + { + "target_id": "template_variables_info", + "position": "append", + "components": [ + { + "id": "bizppurio_alimtalk_section", + "comment": "알림톡 전용 칸 — channel==='alimtalk' 일 때만. 변경 즉시 우리 API 로 저장.", + "type": "basic", + "name": "Div", + "if": "{{_global.notification_template_form_modal?.template?.channel === 'alimtalk'}}", + "props": { + "className": "bg-teal-50 dark:bg-teal-900/20 border border-teal-200 dark:border-teal-700 rounded-lg p-4 space-y-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-start gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Icon", + "props": { + "name": "fas fa-circle-info", + "size": "sm", + "className": "text-teal-600 dark:text-teal-400 flex-shrink-0 mt-0.5" + } + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "H4", + "props": { + "className": "text-sm font-medium text-teal-800 dark:text-teal-200" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_title" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300 mt-0.5" + }, + "text": "$t:sirsoft-message_bizppurio.binding.section_hint" + } + ] + } + ] + }, + { + "id": "bizppurio_binding_template_select", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "text-secondary block text-xs font-medium mb-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.connected_template" + }, + { + "type": "composite", + "name": "Select", + "props": { + "value": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", + "className": "w-full text-sm", + "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "notification_template_form_modal.bizppurio.template_code": "{{$event.target.value}}", + "notification_template_form_modal.bizppurio.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "notification_template_form_modal.bizppurio.fallback_sms": "{{$event.target.value === '' ? false : (_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", + "template_code": "{{$event.target.value}}", + "template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}", + "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0}}", + "props": { + "className": "text-xs text-amber-600 dark:text-amber-400 mt-1" + }, + "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates" + } + ] + }, + { + "id": "bizppurio_binding_fallback_toggle", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "composite", + "name": "Toggle", + "props": { + "checked": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false)}}", + "disabled": "{{(_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')) === ''}}" + }, + "actions": [ + { + "type": "change", + "handler": "sequence", + "params": { + "actions": [ + { + "handler": "setState", + "params": { + "target": "global", + "notification_template_form_modal.bizppurio.fallback_sms": "{{!(_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.fallback_sms_enabled ?? false))}}" + } + }, + { + "handler": "apiCall", + "auth_required": true, + "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings", + "params": { + "method": "POST", + "body": { + "notification_type": "{{_global.notification_template_form_modal?.definition?.type}}", + "template_code": "{{_global.notification_template_form_modal?.bizppurio?.template_code ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_code ?? '')}}", + "template_name": "{{_global.notification_template_form_modal?.bizppurio?.template_name ?? (bizppurioBindings?.data?.bindings?.[_global.notification_template_form_modal?.definition?.type]?.template_name ?? '')}}", + "fallback_sms_enabled": "{{_global.notification_template_form_modal?.bizppurio?.fallback_sms ?? false}}" + } + }, + "onSuccess": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "bizppurioBindings" + } + }, + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.saved", + "type": "success" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "message": "$t:sirsoft-message_bizppurio.binding.save_error", + "type": "error" + } + } + ] + } + ] + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-secondary text-xs" + }, + "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-xs text-teal-700 dark:text-teal-300" + }, + "text": "$t:sirsoft-message_bizppurio.binding.variables_hint" + } + ] + } + ] + } + ], + "priority": 320 +} \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx new file mode 100644 index 00000000..583baf7f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx @@ -0,0 +1,142 @@ +/** + * 알림 설정 알림톡 연동 overlay 구조 검증 (Phase 6 재설계, §6-2) + * + * notification_tab_core.json 은 코어 admin_settings 편집 모달(alimtalk 채널)에 전용 칸을 얹고, + * 알림톡·SMS 탭 상단에 상태 배너를 얹는다. 코어 편집 모달·저장 버튼은 건드리지 않으며(무오염), + * 전용 칸 값을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음). + * + * 검증: + * - 데이터소스(bizppurioBindings·bizppurioApprovedTemplates) 주입 + * - 전용 칸: 안내 카드 · 연결 템플릿 드롭다운(즉시 저장) · SMS 대체 토글(연결 없으면 비활성) + * - 상태 배너: readiness 미충족(설정하기) · 테스트 모드 + * - 코어 편집 모달 저장 body 에 bizppurio 필드가 없다(무오염) + * - i18n 키 정합(ko·en 동일) + */ + +import { describe, it, expect } from 'vitest'; +import overlay from '../../../extensions/notification_tab_core.json'; +import ko from '../../../lang/ko.json'; +import en from '../../../lang/en.json'; +import { findById, type AnyNode } from './helpers'; + +/** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */ +const collectPluginKeys = (json: unknown): string[] => { + const text = JSON.stringify(json); + const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? []; + const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? []; + const keys = [ + ...prefixed.map((m) => m.replace('$t:', '')), + ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')), + ]; + return Array.from(new Set(keys)); +}; + +type Overlay = { + target_layout: string; + data_sources?: Array<{ id: string; endpoint: string }>; + injections: Array<{ target_id: string; position: string; components: AnyNode[] }>; +}; + +const ext = overlay as unknown as Overlay; + +/** injection components 를 하나의 검색 가능한 루트로 감싼다. */ +const injectionRoot = (targetId: string): AnyNode => { + const inj = ext.injections.find((i) => i.target_id === targetId); + return { children: inj?.components ?? [] } as AnyNode; +}; + +describe('notification binding overlay — 대상·데이터소스', () => { + it('코어 admin_settings 레이아웃을 대상으로 한다', () => { + expect(ext.target_layout).toBe('admin_settings'); + }); + + it('연동 맵·승인 템플릿 데이터소스를 주입한다', () => { + const ids = (ext.data_sources ?? []).map((d) => d.id); + expect(ids).toContain('bizppurioBindings'); + expect(ids).toContain('bizppurioApprovedTemplates'); + }); + + it('전용 칸은 코어 편집 모달의 template_variables_info 뒤에 append 된다', () => { + const inj = ext.injections.find((i) => i.target_id === 'template_variables_info'); + expect(inj).toBeTruthy(); + expect(inj?.position).toBe('append'); + }); +}); + +describe('notification binding overlay — 전용 칸', () => { + const root = injectionRoot('template_variables_info'); + + it('알림톡 전용 섹션은 channel === alimtalk 일 때만 노출된다', () => { + const section = findById(root, 'bizppurio_alimtalk_section'); + expect(section).toBeTruthy(); + expect((section as { if?: string }).if).toContain("=== 'alimtalk'"); + }); + + it('연결 템플릿 드롭다운을 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음)', () => { + const raw = JSON.stringify(findById(root, 'bizppurio_binding_template_select')); + expect(raw).toContain('apiCall'); + expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings'); + expect(raw).toContain('"method":"POST"'); + expect(raw).toContain('binding.saved'); + }); + + it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이고, 변경 시 즉시 저장한다', () => { + const toggleWrap = findById(root, 'bizppurio_binding_fallback_toggle'); + const raw = JSON.stringify(toggleWrap); + expect(raw).toContain('"disabled"'); + expect(raw).toContain("=== ''"); + expect(raw).toContain('apiCall'); + expect(raw).toContain('"method":"POST"'); + }); + + it('저장 실패 시 에러 토스트를 띄운다', () => { + const raw = JSON.stringify(root); + expect(raw).toContain('binding.save_error'); + }); +}); + +describe('notification binding overlay — 상태 배너', () => { + const root = injectionRoot('notif_channel_content'); + + it('배너는 sms·alimtalk 탭에서 문제가 있을 때만 노출된다', () => { + const banner = findById(root, 'bizppurio_status_banner'); + expect(banner).toBeTruthy(); + const cond = (banner as { if?: string }).if ?? ''; + expect(cond).toContain("'sms'"); + expect(cond).toContain("'alimtalk'"); + expect(cond).toContain('readiness?.ready === false'); + expect(cond).toContain('is_test_mode === true'); + }); + + it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => { + const raw = JSON.stringify(findById(root, 'bizppurio_banner_not_ready')); + expect(raw).toContain('banner.not_ready'); + expect(raw).toContain('banner.setup_action'); + expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings'); + }); + + it('테스트 모드 배너는 readiness 충족 + is_test_mode 일 때만 노출된다', () => { + const testBanner = findById(root, 'bizppurio_banner_test_mode'); + const cond = (testBanner as { if?: string }).if ?? ''; + expect(cond).toContain('readiness?.ready !== false'); + expect(cond).toContain('is_test_mode === true'); + expect(JSON.stringify(testBanner)).toContain('banner.test_mode'); + }); +}); + +describe('notification binding overlay — i18n 정합', () => { + it('overlay 가 참조하는 모든 키가 ko·en 에 존재한다', () => { + const keys = collectPluginKeys(overlay); + expect(keys.length).toBeGreaterThan(0); + + // 플러그인 resources/lang/{ko,en}.json 파일 루트가 곧 sirsoft-message_bizppurio 네임스페이스. + // 키 `sirsoft-message_bizppurio.binding.saved` → 첫 세그먼트(네임스페이스) 제거 후 파일 루트에서 해석. + const resolve = (root: unknown, path: string): unknown => + path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root); + + for (const key of keys) { + expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy(); + expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy(); + } + }); +}); \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json index a13ba160..d6a7f6e9 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json @@ -62,6 +62,22 @@ "templates": "Alimtalk Templates" } }, + "binding": { + "section_title": "KakaoTalk Alimtalk Binding", + "section_hint": "Changes are saved instantly, separately from the message body.", + "connected_template": "Connected Template", + "none": "Not connected", + "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.", + "fallback_sms": "Fall back to SMS on failure", + "variables_hint": "Available variables are auto-substituted on send.", + "saved": "Alimtalk binding saved.", + "save_error": "Failed to save alimtalk binding." + }, + "banner": { + "not_ready": "Setup required for delivery is incomplete.", + "setup_action": "Set up", + "test_mode": "Test mode — messages are not actually sent." + }, "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 8dca6795..024fd0ed 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json @@ -62,6 +62,22 @@ "templates": "알림톡 템플릿" } }, + "binding": { + "section_title": "카카오 알림톡 연동", + "section_hint": "선택하면 즉시 저장됩니다. 본문 저장과 별개로 동작합니다.", + "connected_template": "연결 템플릿", + "none": "연결 안 함", + "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.", + "fallback_sms": "실패 시 SMS로 대체발송", + "variables_hint": "제공 변수는 발송 시 자동 치환됩니다.", + "saved": "알림톡 연동을 저장했습니다.", + "save_error": "알림톡 연동 저장에 실패했습니다." + }, + "banner": { + "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.", + "setup_action": "설정하기", + "test_mode": "테스트 모드입니다 — 실제 발송은 이뤄지지 않습니다." + }, "templates": { "title": "알림톡 템플릿", "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.", diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php new file mode 100644 index 00000000..0694e26f --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php @@ -0,0 +1,42 @@ + $e->getMessage(), + 'result_code' => $e->getResultCode(), + ], + ); + } + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php index c2353e4b..47a981f4 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php @@ -8,7 +8,7 @@ use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException; +use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreAlimtalkTemplateRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateAlimtalkTemplateRequest; use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UploadTemplateImageRequest; @@ -29,6 +29,8 @@ use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService; */ class AlimtalkTemplateController extends AdminBaseController { + use GuardsKakaoRequests; + /** * @param AlimtalkTemplateService $service 알림톡 템플릿 서비스 */ @@ -44,6 +46,7 @@ class AlimtalkTemplateController extends AdminBaseController * 쿼리: status(templateStatus)·keyword(2~50자)·page·count * * @param Request $request HTTP 요청 + * @return JsonResponse data 에 templates·pagination */ public function index(Request $request): JsonResponse { @@ -63,6 +66,7 @@ class AlimtalkTemplateController extends AdminBaseController * 알림톡 템플릿 상세를 실시간 조회합니다. * * @param string $templateCode 템플릿 코드 + * @return JsonResponse data.template 에 배지·가능 액션이 부가된 상세 */ public function show(string $templateCode): JsonResponse { @@ -73,6 +77,8 @@ class AlimtalkTemplateController extends AdminBaseController /** * 템플릿 등록에 사용할 카테고리 전체를 조회합니다. + * + * @return JsonResponse data.categories 에 대분류·소분류 목록 */ public function categories(): JsonResponse { @@ -83,6 +89,8 @@ class AlimtalkTemplateController extends AdminBaseController /** * 발신프로필(사용중) 정보를 조회합니다. + * + * @return JsonResponse data.profiles 에 발신프로필 상태 정보 */ public function profiles(): JsonResponse { @@ -98,6 +106,7 @@ class AlimtalkTemplateController extends AdminBaseController * templateImageUrl 로 사용한다. * * @param UploadTemplateImageRequest $request 이미지 파일 검증 Request + * @return JsonResponse data 에 image_url·image_name */ public function uploadImage(UploadTemplateImageRequest $request): JsonResponse { @@ -120,6 +129,7 @@ class AlimtalkTemplateController extends AdminBaseController * 알림톡 템플릿을 신규 등록합니다. * * @param StoreAlimtalkTemplateRequest $request 유형별 검증 Request + * @return JsonResponse data.template 에 등록된 템플릿(검수요청 시 REQ 상태) */ public function store(StoreAlimtalkTemplateRequest $request): JsonResponse { @@ -154,6 +164,7 @@ class AlimtalkTemplateController extends AdminBaseController * * @param UpdateAlimtalkTemplateRequest $request 유형별 검증 Request * @param string $templateCode 기존 템플릿 코드 + * @return JsonResponse data.template 에 수정된 템플릿 */ public function update(UpdateAlimtalkTemplateRequest $request, string $templateCode): JsonResponse { @@ -167,6 +178,7 @@ class AlimtalkTemplateController extends AdminBaseController * 알림톡 템플릿을 삭제합니다. (대기 + 검수 REG/REJ 상태만) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 삭제 완료 응답 */ public function destroy(string $templateCode): JsonResponse { @@ -182,6 +194,7 @@ class AlimtalkTemplateController extends AdminBaseController * * @param Request $request HTTP 요청(comment 선택) * @param string $templateCode 템플릿 코드 + * @return JsonResponse 검수 요청 완료 응답 */ public function requestInspection(Request $request, string $templateCode): JsonResponse { @@ -196,6 +209,7 @@ class AlimtalkTemplateController extends AdminBaseController * 검수 요청을 취소합니다. (검수 REQ 상태만) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 검수 취소 완료 응답 */ public function cancelRequest(string $templateCode): JsonResponse { @@ -206,6 +220,7 @@ class AlimtalkTemplateController extends AdminBaseController * 승인된 템플릿을 중지합니다. (RDY/ACT 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 중지 완료 응답 */ public function stop(string $templateCode): JsonResponse { @@ -216,6 +231,7 @@ class AlimtalkTemplateController extends AdminBaseController * 중지된 템플릿을 정상으로 되돌립니다. (STP 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 중지 해제 완료 응답 */ public function reuse(string $templateCode): JsonResponse { @@ -226,6 +242,7 @@ class AlimtalkTemplateController extends AdminBaseController * 승인을 취소합니다(재검수 가능 상태로 복귀). (RDY/ACT 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 승인 취소 완료 응답 */ public function cancelApproval(string $templateCode): JsonResponse { @@ -236,6 +253,7 @@ class AlimtalkTemplateController extends AdminBaseController * 휴면 템플릿을 해제합니다. (DMT 상태) * * @param string $templateCode 템플릿 코드 + * @return JsonResponse 휴면 해제 완료 응답 */ public function release(string $templateCode): JsonResponse { @@ -266,19 +284,5 @@ class AlimtalkTemplateController extends AdminBaseController * * @param callable(): JsonResponse $callback 실제 처리 */ - private function guard(callable $callback): JsonResponse - { - try { - return $callback(); - } catch (BizppurioApiException $e) { - return ResponseHelper::error( - 'sirsoft-message_bizppurio::messages.error.kakao_request_failed', - 422, - [ - 'kakao_message' => $e->getMessage(), - 'result_code' => $e->getResultCode(), - ], - ); - } - } + // guard() 는 GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유). } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php new file mode 100644 index 00000000..c7778ce6 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php @@ -0,0 +1,95 @@ + $this->service->all(), + ]); + } + + /** + * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 드롭다운). + * + * kapi 실패는 카카오가 준 사유를 그대로 422 로 반환한다. + * + * @return JsonResponse data.templates 에 승인 템플릿(code/name) 배열, 실패 시 422 + */ + public function approvedTemplates(): JsonResponse + { + return $this->guard(fn () => ResponseHelper::success('messages.success', [ + 'templates' => $this->service->approvedTemplates(), + ])); + } + + /** + * 연결 템플릿·SMS 대체를 즉시 저장합니다 (전용 칸 변경 시 자동 호출). + * + * 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다("빈 코드=해제" 규칙 — + * 드롭다운에서 "연결 안 함"을 고르면 해제까지 한 번에 처리). SMS 대체는 연결이 있을 때만 + * 의미가 있다. + * + * @param StoreNotificationBindingRequest $request 검증된 연동 입력 + * @return JsonResponse 저장 결과 (해제 시에도 200) + */ + public function store(StoreNotificationBindingRequest $request): JsonResponse + { + $validated = $request->validated(); + + $this->service->applyFromTemplateSave( + $validated['notification_type'], + $validated['template_code'] ?? null, + $validated['template_name'] ?? null, + (bool) ($validated['fallback_sms_enabled'] ?? false), + ); + + return ResponseHelper::success('messages.binding.saved', [ + 'bindings' => $this->service->all(), + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php new file mode 100644 index 00000000..4a7f9e9d --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php @@ -0,0 +1,39 @@ + + */ + public function rules(): array + { + return [ + 'notification_type' => ['required', 'string', 'max:100'], + 'template_code' => ['nullable', 'string', 'max:30'], + 'template_name' => ['nullable', 'string', 'max:255'], + 'fallback_sms_enabled' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php index 3612d29f..239e94c8 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php @@ -23,8 +23,10 @@ use App\Services\PluginSettingsService; * ChannelManager 에 등록되어야 발화하므로, 드라이버 등록은 ServiceProvider::boot() 가 * 담당한다(이 리스너는 채널 "노출·판정"만 책임진다). * - * alimtalk 의 uses_custom_list 플래그(알림톡 탭 배타 전환)는 탭 UI 주입(§6-2)과 강결합이라 - * Phase 6 에서 이 리스너에 추가한다. Phase 3 에서는 채널 메타·readiness·발송 위임 골격만 둔다. + * 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 사용한다(Phase 6 재설계, 계획서 ⚑⚑ 블록 A). + * 연결 템플릿·SMS 대체 등 알림톡 전용 설정은 코어 편집 모달에 전용 칸을 overlay 로 얹고 + * 코어 저장 훅(core.notification_template.filter_update_data/after_update)에 편승해 저장하므로, + * 이 리스너는 코어 목록을 숨기는 별도 플래그를 두지 않는다. */ class RegisterNotificationChannelsListener implements HookListenerInterface { @@ -105,8 +107,14 @@ class RegisterNotificationChannelsListener implements HookListenerInterface { $existingIds = array_column($channels, 'id'); + // 검수(테스트) 모드 여부를 채널 메타에 실어 프론트(availableChannels)로 전달한다. + // 알림톡·SMS 탭 상단 상태 배너가 이 값으로 "테스트 모드 — 실제 발송 안 됨"을 노출한다. + // 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다. + $isTestMode = $this->isTestMode(); + foreach ($this->channelMetas() as $meta) { if (! in_array($meta['id'], $existingIds, true)) { + $meta['is_test_mode'] = $isTestMode; $channels[] = $meta; } } @@ -245,6 +253,18 @@ class RegisterNotificationChannelsListener implements HookListenerInterface return trim((string) $value) === ''; } + /** + * 검수(테스트) 모드 여부를 반환합니다. + * + * is_test_mode=true 이면 실제 발송이 이뤄지지 않는다(검수 환경). 상태 배너 노출 기준. + * + * @return bool 검수 모드면 true + */ + private function isTestMode(): bool + { + return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true); + } + /** * ready=false 판정을 lang reason key 와 함께 반환합니다. * diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php new file mode 100644 index 00000000..f0455c70 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php @@ -0,0 +1,260 @@ + [...], ...]` (키가 type) + * - 모듈: 순차 배열 `[['type' => 'order_confirmed', ...], ...]` + * 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다. + * + * 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은 + * 문자/알림톡 대상이 아니므로 건너뛴다. 기본 body 는 그 알림의 database 채널 body(짧은 평문)를 + * 재활용하고, 없으면 mail body 의 HTML 을 제거해 만든다. + */ +class SeedChannelTemplatesListener implements HookListenerInterface +{ + /** 증강할 채널 id 목록 */ + private const CHANNELS = ['sms', 'alimtalk']; + + /** + * 구독할 훅 목록 반환. + * + * @return array> + */ + public static function getSubscribedHooks(): array + { + $hooks = []; + foreach ([ + 'seed.notifications.translations', + 'seed.sirsoft-board.notifications.translations', + 'seed.sirsoft-ecommerce.notifications.translations', + ] as $hook) { + $hooks[$hook] = [ + 'method' => 'augment', + 'priority' => 50, + 'type' => 'filter', + ]; + } + + return $hooks; + } + + /** + * 기본 핸들러 (미사용 — 필터 메서드로 처리). + * + * @param mixed ...$args + */ + public function handle(...$args): void {} + + /** + * 정의 배열의 각 회원 대상 알림에 sms·alimtalk 채널 template 을 증강합니다. + * + * 키(코어=type, 모듈=정수)와 순서를 보존한 채 각 정의 값만 변형한다. 정의 형태가 배열이 + * 아니거나 회원 대상이 아니면 원본을 그대로 둔다. + * + * @param array $definitions 시딩 직전 알림 정의 배열 + * @return array 채널이 증강된 정의 배열 + */ + public function augment(array $definitions): array + { + foreach ($definitions as $key => $definition) { + if (! is_array($definition) || ! $this->isMemberFacing($definition)) { + continue; + } + + $definitions[$key] = $this->augmentDefinition($definition); + } + + return $definitions; + } + + /** + * 단일 알림 정의에 sms·alimtalk 채널을 channels·templates 에 추가합니다. + * + * 이미 해당 채널이 선언돼 있으면(다른 경로로 이미 존재) 중복 추가하지 않는다. + * + * @param array $definition 단일 알림 정의 + * @return array 증강된 정의 + */ + private function augmentDefinition(array $definition): array + { + $channels = $definition['channels'] ?? ['mail']; + $templates = $definition['templates'] ?? []; + + $existingChannels = array_column($templates, 'channel'); + $baseBody = $this->resolveBaseBody($templates); + $baseSubject = $this->resolveBaseSubject($templates); + $recipients = $this->resolveRecipients($templates); + + foreach (self::CHANNELS as $channel) { + if (in_array($channel, $existingChannels, true)) { + continue; + } + + if (! in_array($channel, $channels, true)) { + $channels[] = $channel; + } + + $templates[] = [ + 'channel' => $channel, + 'recipients' => $recipients, + 'subject' => $baseSubject, + 'body' => $baseBody, + ]; + } + + $definition['channels'] = $channels; + $definition['templates'] = $templates; + + return $definition; + } + + /** + * 알림이 회원(사용자) 대상인지 판정합니다 (결정 E). + * + * 정의의 어떤 template recipients 든 관리자 전용(type=role, value=admin)이 아닌 수신자가 + * 하나라도 있으면 회원 대상으로 본다. recipients 가 전혀 없으면(수신자 미지정) 회원 발송 + * 기본값으로 포함한다. channels 컬럼이 아니라 recipients 로 판정한다(결정 E). + * + * @param array $definition 단일 알림 정의 + * @return bool 회원 대상이면 true + */ + private function isMemberFacing(array $definition): bool + { + $templates = $definition['templates'] ?? []; + $hasAnyRecipient = false; + + foreach ($templates as $template) { + foreach ($template['recipients'] ?? [] as $recipient) { + $hasAnyRecipient = true; + + $type = $recipient['type'] ?? null; + $value = $recipient['value'] ?? null; + + if (! ($type === 'role' && $value === 'admin')) { + return true; + } + } + } + + return ! $hasAnyRecipient; + } + + /** + * 증강할 채널의 기본 body(다국어 배열)를 결정합니다. + * + * 우선순위: database 채널 body(짧은 평문) → mail body 의 HTML 제거본 → 빈 배열. + * 문자/알림톡은 평문이므로 HTML 을 담지 않는다. + * + * @param array> $templates 기존 template 목록 + * @return array locale → 평문 body + */ + private function resolveBaseBody(array $templates): array + { + $database = $this->findTemplate($templates, 'database'); + if ($database !== null && is_array($database['body'] ?? null)) { + return $database['body']; + } + + $mail = $this->findTemplate($templates, 'mail'); + if ($mail !== null && is_array($mail['body'] ?? null)) { + return array_map(fn ($body) => $this->stripHtml((string) $body), $mail['body']); + } + + return []; + } + + /** + * 증강할 채널의 기본 subject(다국어 배열)를 결정합니다. + * + * database → mail subject 순으로 재활용한다. LMS 전환 시 제목으로 쓰인다. + * + * @param array> $templates 기존 template 목록 + * @return array locale → subject + */ + private function resolveBaseSubject(array $templates): array + { + foreach (['database', 'mail'] as $channel) { + $template = $this->findTemplate($templates, $channel); + if ($template !== null && is_array($template['subject'] ?? null)) { + return $template['subject']; + } + } + + return []; + } + + /** + * 증강할 채널의 recipients 를 결정합니다. + * + * 기존 template(database → mail)의 recipients 를 그대로 재활용해 수신자 규칙을 일치시킨다. + * 없으면 발생 회원 본인(trigger_user) 기본값을 쓴다. + * + * @param array> $templates 기존 template 목록 + * @return array> recipients 규칙 + */ + private function resolveRecipients(array $templates): array + { + foreach (['database', 'mail'] as $channel) { + $template = $this->findTemplate($templates, $channel); + if ($template !== null && ! empty($template['recipients']) && is_array($template['recipients'])) { + return $template['recipients']; + } + } + + return [['type' => 'trigger_user']]; + } + + /** + * template 목록에서 특정 채널의 template 을 찾습니다. + * + * @param array> $templates template 목록 + * @param string $channel 찾을 채널 + * @return array|null 매칭 template 또는 null + */ + private function findTemplate(array $templates, string $channel): ?array + { + foreach ($templates as $template) { + if (($template['channel'] ?? null) === $channel) { + return $template; + } + } + + return null; + } + + /** + * HTML 문자열을 문자/알림톡용 평문으로 변환합니다. + * + * 태그 제거 + 엔티티 디코드 + 연속 공백 정리. 변수 표기({name} 등)는 보존된다. + * + * @param string $html 원본 HTML + * @return string 평문 + */ + private function stripHtml(string $html): string + { + $text = preg_replace('/<[^>]+>/', ' ', $html) ?? ''; + $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + return trim((string) preg_replace('/\s+/', ' ', $text)); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php index 86e75ffd..774cedf1 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php @@ -6,11 +6,11 @@ namespace Plugins\Sirsoft\MessageBizppurio\Providers; use App\Extension\BasePluginServiceProvider; use Illuminate\Notifications\ChannelManager; -use Illuminate\Notifications\Notification; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository; use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface; use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface; +use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService; use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver; use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService; @@ -75,9 +75,9 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * SmsChannelDriver::send() 로 위임되게 한다. 플러그인 비활성/삭제 시 이 등록도 * 사라져 코어가 안전하게 원복된다. * - * 알림톡('alimtalk')은 채널 등록(RegisterNotificationChannelsListener)만 이번 Phase 에 - * 하고 실제 발송 드라이버는 Phase 6 이다. 그 사이 alimtalk 채널로 발송이 트리거될 경우의 - * 크래시를 막기 위해, 아직은 no-op 스텁 드라이버로 등록해 둔다(Phase 6 에서 실드라이버로 교체). + * 알림톡('alimtalk')도 동일하게 AlimtalkChannelDriver 로 위임한다(Phase 6). 관리자가 + * 알림톡 탭에서 연결한 승인 템플릿(binding)이 있을 때만 실제 발송되고, 미연결 알림은 + * 드라이버가 조용히 skip 한다. */ public function boot(): void { @@ -100,22 +100,14 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider * 코어 알림 ChannelManager 에 이 플러그인의 채널 드라이버를 등록합니다. * * - sms: SmsChannelDriver 로 실제 발송 위임(Phase 3). - * - alimtalk: 채널 등록만 이번 Phase(§6-2·Phase 6). 실발송 전까지 no-op 스텁으로 - * 등록해 alimtalk 발송 트리거 시 "Driver not supported" 크래시를 막는다. + * - alimtalk: AlimtalkChannelDriver 로 실제 발송 위임(Phase 6). 연결된 승인 템플릿 + * (binding)이 있을 때만 발송하고 미연결 알림은 드라이버가 skip 한다. * * @param ChannelManager $manager 코어 알림 채널 매니저 */ private function registerChannelDrivers(ChannelManager $manager): void { $manager->extend('sms', fn ($app) => $app->make(SmsChannelDriver::class)); - - $manager->extend('alimtalk', fn () => new class - { - /** - * @param object $notifiable - * @param Notification $notification - */ - public function send($notifiable, $notification): void {} - }); + $manager->extend('alimtalk', fn ($app) => $app->make(AlimtalkChannelDriver::class)); } } diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php new file mode 100644 index 00000000..4b8f5bf9 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php @@ -0,0 +1,217 @@ +getType(); + + // 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송) + $binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value); + if ($binding === null) { + Log::info('비즈뿌리오 알림톡 발송 skip — 연결된 템플릿 없음', ['type' => $type]); + + return; + } + + // 2. 코어 alimtalk 템플릿 본문 resolve (없으면 발송 안 함 — 기본 body 시드 전제) + $template = $this->templateService->resolve($type, DispatchChannel::Alimtalk->value); + if ($template === null || ! $template->is_active) { + Log::info('비즈뿌리오 알림톡 발송 skip — alimtalk 템플릿 없음', ['type' => $type]); + + return; + } + + // 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone) + $to = $this->resolvePhone($notifiable, $notification->getData()); + if ($to === null) { + Log::info('비즈뿌리오 알림톡 발송 skip — 전화번호 없음', ['type' => $type]); + + return; + } + + // 4. 본문 렌더 (변수 치환) → 알림톡 변수 형식으로 변환 ({x} → #{x}) + $locale = BaseNotification::resolveNotifiableLocale($notifiable); + $rendered = $template->replaceVariables($notification->getData(), $locale); + $message = $this->toAlimtalkVariables((string) ($rendered['body'] ?? '')); + if (trim($message) === '') { + Log::info('비즈뿌리오 알림톡 발송 skip — 본문 비어 있음', ['type' => $type]); + + return; + } + + // 5. refkey 생성 → payload 조립 (대체발송 ON 시 SMS resend 병합) + $refkey = $this->generateRefkey(); + $payload = $this->payloadBuilder->buildAlimtalk($to, $binding->template_code, $message, $refkey); + + if ($binding->fallback_sms_enabled) { + $payload = $this->withSmsFallback($payload, (string) ($rendered['body'] ?? '')); + } + + // 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신. + $this->dispatches->create([ + 'refkey' => $refkey, + 'channel' => DispatchChannel::Alimtalk->value, + 'to_number' => $to, + 'to_name' => $notifiable->name ?? null, + 'to_user_id' => $this->resolveUserId($notifiable), + 'content' => $message, + 'notification_type' => $type, + 'status' => DispatchStatus::Pending->value, + 'source' => DispatchSource::Auto->value, + 'sent_at' => now(), + ]); + + SendMessageJob::dispatch($payload, $refkey); + } + + /** + * 코어 알림 본문의 변수 형식({name})을 알림톡 변수 형식(#{name})으로 변환합니다 (D8). + * + * 코어 알림 템플릿과 카카오 알림톡은 변수명 규칙이 동일하되 표기만 다르다. 변수명을 그대로 + * 유지한 채 접두 `#` 만 붙여, 매핑 UI·컬럼 없이 자동 치환한다. 이미 `#{...}` 인 경우는 + * 중복 변환하지 않는다. + * + * @param string $body 코어 본문 (예: "{name} 님 주문 {order_number}") + * @return string 알림톡 본문 (예: "#{name} 님 주문 #{order_number}") + */ + private function toAlimtalkVariables(string $body): string + { + // 앞에 # 가 없는 {var} 만 #{var} 로 변환 (이미 #{var} 인 경우 보존). + return (string) preg_replace('/(? $payload 알림톡 발송 payload + * @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용) + * @return array resend/recontent 가 병합된 payload + */ + private function withSmsFallback(array $payload, string $renderedBody): array + { + $payload['resend'] = ['first' => 'sms']; + $payload['recontent'] = ['sms' => ['message' => $renderedBody]]; + + return $payload; + } + + /** + * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다. + * + * @param object $notifiable 수신자 + * @return int|null 회원 ID 또는 null + */ + private function resolveUserId(object $notifiable): ?int + { + if ($notifiable instanceof User) { + return (int) $notifiable->getKey(); + } + + return null; + } + + /** + * 수신자의 전화번호를 해석합니다 (SmsChannelDriver 와 동일 규칙). + * + * 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다. + * 숫자 외 문자는 제거하고, 값이 없으면 null 을 반환한다. + * + * @param object $notifiable 수신자 + * @param array $data 알림 data + * @return string|null 정규화된 전화번호 또는 null + */ + private function resolvePhone(object $notifiable, array $data): ?string + { + $raw = $notifiable->mobile + ?? ($data[self::RECIPIENT_PHONE_KEY] ?? null); + + $normalized = preg_replace('/[^0-9]/', '', (string) $raw); + + return ($normalized === null || $normalized === '') ? null : $normalized; + } + + /** + * webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다. + * + * @return string 32자 refkey + */ + private function generateRefkey(): string + { + return Str::random(32); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php new file mode 100644 index 00000000..eba3bd60 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php @@ -0,0 +1,151 @@ +> notification_type 키의 연동 맵 + */ + public function all(): array + { + return $this->bindings->allByChannel(self::CHANNEL) + ->keyBy('notification_type') + ->map(fn (BizppurioNotificationBinding $binding) => [ + 'notification_type' => $binding->notification_type, + 'template_code' => $binding->template_code, + 'template_name' => $binding->template_name, + 'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled, + ]) + ->all(); + } + + /** + * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운). + * + * 카카오 템플릿 목록 중 serviceStatus 가 RDY/ACT 인 항목만 노출한다. 미승인 템플릿에 + * 연동해도 발송이 거부되므로 애초에 선택지에서 제외한다. + * + * @return array + * + * @throws BizppurioApiException 자격증명 미설정·조회 실패 시 + */ + public function approvedTemplates(): array + { + // 승인 상태 필터는 kapi 에 status 파라미터로 위임하지 않고(상태 2종 조회 불가), 전체를 + // 받아 serviceStatus 로 거른다. 페이지네이션 대신 넉넉한 count 로 1회 조회한다. + $result = $this->templates->list(['count' => 100]); + + return collect($result['templates'] ?? []) + ->filter(fn (array $row) => in_array((string) ($row['serviceStatus'] ?? $row['status'] ?? ''), self::SENDABLE_STATUSES, true)) + ->map(fn (array $row) => [ + 'template_code' => (string) ($row['templateCode'] ?? $row['code'] ?? ''), + 'template_name' => (string) ($row['templateName'] ?? $row['name'] ?? ''), + ]) + ->filter(fn (array $row) => $row['template_code'] !== '') + ->values() + ->all(); + } + + /** + * 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장). + * + * @param string $notificationType 코어 notification_definitions.type + * @param array $data template_code / template_name / fallback_sms_enabled + * @return BizppurioNotificationBinding 저장된 연동 + */ + public function bind(string $notificationType, array $data): BizppurioNotificationBinding + { + return $this->bindings->upsert($notificationType, self::CHANNEL, [ + 'template_code' => (string) $data['template_code'], + 'template_name' => (string) $data['template_name'], + 'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false), + 'is_active' => true, + ]); + } + + /** + * 알림의 알림톡 연동을 해제(삭제)합니다. + * + * @param string $notificationType 코어 notification_definitions.type + */ + public function unbind(string $notificationType): void + { + $this->bindings->delete($notificationType, self::CHANNEL); + } + + /** + * 코어 편집 모달 전용 칸이 넘긴 값으로 연동을 반영합니다 (저장 훅 편승). + * + * 코어 알림 템플릿 [저장] 훅(after_update)에서 호출된다. 연결 템플릿 코드가 비어 있으면 + * 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제" 규칙 덕분에 편집 모달에서 드롭다운을 + * "연결 안 함"으로 바꾸고 저장하면 코어 저장 한 번으로 해제까지 처리된다. + * + * @param string $notificationType 코어 notification_definitions.type + * @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제) + * @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용) + * @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부 + */ + public function applyFromTemplateSave( + string $notificationType, + ?string $templateCode, + ?string $templateName, + bool $fallbackSmsEnabled, + ): void { + $code = trim((string) $templateCode); + + if ($code === '') { + $this->unbind($notificationType); + + return; + } + + $this->bind($notificationType, [ + 'template_code' => $code, + 'template_name' => trim((string) $templateName), + 'fallback_sms_enabled' => $fallbackSmsEnabled, + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php index e5897118..30d29971 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\NotificationBindingController; use Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController; use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist; @@ -82,4 +83,27 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g Route::post('/{templateCode}/release', [AlimtalkTemplateController::class, 'release'])->name('release'); }); }); + + /* + |---------------------------------------------------------------------- + | 알림↔알림톡 템플릿 연동 (Phase 6 재설계) — 코어 편집 모달 전용 칸이 소비 + |---------------------------------------------------------------------- + | + | 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체 + | 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이 API + | 로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼 무관 → + | 코어 템플릿 무오염). + | + | 조회(index/approved-templates) = messaging.view / 저장(store) = messaging.manage. + */ + Route::prefix('notification-bindings')->name('notification-bindings.')->group(function () { + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () { + Route::get('/', [NotificationBindingController::class, 'index'])->name('index'); + Route::get('/approved-templates', [NotificationBindingController::class, 'approvedTemplates'])->name('approved-templates'); + }); + + Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () { + Route::post('/', [NotificationBindingController::class, 'store'])->name('store'); + }); + }); }); diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php new file mode 100644 index 00000000..2b58b5d4 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php @@ -0,0 +1,191 @@ + $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_binding_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', + ]; + } + + public function test_인증_없이_목록_조회는_401이다(): void + { + $this->getJson(self::BASE)->assertStatus(401); + } + + public function test_view_권한으로_연동_맵을_조회한다(): void + { + BizppurioNotificationBinding::create([ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => true, + 'is_active' => true, + ]); + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE); + + $response->assertStatus(200); + $response->assertJsonPath('data.bindings.welcome.template_code', 'TW_1236'); + $response->assertJsonPath('data.bindings.welcome.fallback_sms_enabled', true); + } + + public function test_연동이_없으면_빈_맵을_반환한다(): void + { + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE); + + $response->assertStatus(200); + $response->assertJsonPath('data.bindings', []); + } + + public function test_권한_없는_사용자는_승인템플릿_조회_403이다(): void + { + $admin = $this->adminWith([]); // messaging.view 없음 + + $response = $this->withHeaders($this->authHeaders($admin)) + ->getJson(self::BASE.'/approved-templates'); + + $response->assertStatus(403); + } + + public function test_view_권한만으로는_저장할_수_없다(): void + { + $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + ]); + + $response->assertStatus(403); + } + + public function test_manage_권한으로_연동을_즉시_저장한다(): void + { + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => true, + ]); + + $response->assertStatus(200); + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'fallback_sms_enabled' => true, + ]); + } + + public function test_빈_코드로_저장하면_연동을_해제한다(): void + { + BizppurioNotificationBinding::create([ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => false, + 'is_active' => true, + ]); + + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'notification_type' => 'welcome', + 'template_code' => '', + ]); + + $response->assertStatus(200); + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + ]); + } + + public function test_notification_type_누락_시_422다(): void + { + $admin = $this->adminWith([ + 'sirsoft-message_bizppurio.messaging.view', + 'sirsoft-message_bizppurio.messaging.manage', + ]); + + $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [ + 'template_code' => 'TW_1236', + ]); + + $response->assertStatus(422); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php index ca89a6be..4e31bd6f 100644 --- a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php @@ -72,13 +72,39 @@ class RegisterNotificationChannelsListenerTest extends PluginTestCase $this->assertTrue($alimtalk['allow_guest']); } - public function test_알림톡_채널에는_uses_custom_list_플래그가_아직_없다(): void + public function test_채널메타에_is_test_mode가_실린다(): void { - // 탭 배타 전환 플래그는 Phase 6 — 이번 Phase 채널 메타에는 없어야 한다. - $channels = $this->makeListener()->addChannels([]); - $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + // is_test_mode=true(검수) → 상태 배너 노출 기준. 채널 메타에 실려 프론트로 전달된다. + $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => true])); + $channels = $listener->addChannels([]); - $this->assertArrayNotHasKey('uses_custom_list', $alimtalk); + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertTrue($alimtalk['is_test_mode'], '검수 모드면 채널 메타 is_test_mode=true.'); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertTrue($sms['is_test_mode']); + } + + public function test_검수모드_해제시_is_test_mode가_false다(): void + { + $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => false])); + $channels = $listener->addChannels([]); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertFalse($alimtalk['is_test_mode'], '운영 모드면 is_test_mode=false.'); + } + + public function test_어떤_채널에도_uses_custom_list_플래그가_없다(): void + { + // Phase 6 재설계 A(⚑⚑ 결정 A): 알림톡 탭도 코어 기본 목록을 그대로 쓴다. 코어 목록을 + // 숨기던 uses_custom_list 게이트·플래그는 폐기되었으므로 어느 채널에도 있으면 안 된다. + $channels = $this->makeListener()->addChannels([]); + + $alimtalk = collect($channels)->firstWhere('id', 'alimtalk'); + $this->assertArrayNotHasKey('uses_custom_list', $alimtalk, '알림톡은 코어 기본 목록을 쓰므로 플래그가 없어야 한다(재설계 A).'); + + $sms = collect($channels)->firstWhere('id', 'sms'); + $this->assertArrayNotHasKey('uses_custom_list', $sms, 'sms 는 코어 기본 목록을 쓰므로 플래그가 없어야 한다.'); } public function test_이미_존재하는_채널은_중복_추가되지_않는다(): void diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php new file mode 100644 index 00000000..d4d7a57a --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php @@ -0,0 +1,149 @@ + 'trigger_user']]): array + { + return [ + 'welcome' => [ + 'hook_prefix' => 'core.auth', + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => $recipients, 'subject' => ['ko' => '제목'], 'body' => ['ko' => '

{name}

']], + ['channel' => 'database', 'recipients' => $recipients, 'subject' => ['ko' => '알림'], 'body' => ['ko' => '{name}님 환영합니다']], + ], + ], + ]; + } + + public function test_회원_알림에_sms와_alimtalk_채널이_추가된다(): void + { + $result = $this->listener()->augment($this->coreDefinition()); + + $channels = $result['welcome']['channels']; + $this->assertContains('sms', $channels); + $this->assertContains('alimtalk', $channels); + $this->assertContains('mail', $channels, '기존 채널은 보존되어야 한다.'); + + $addedChannels = array_column($result['welcome']['templates'], 'channel'); + $this->assertContains('sms', $addedChannels); + $this->assertContains('alimtalk', $addedChannels); + } + + public function test_추가된_채널_body는_database_평문을_재활용한다(): void + { + $result = $this->listener()->augment($this->coreDefinition()); + + $alimtalk = collect($result['welcome']['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertSame('{name}님 환영합니다', $alimtalk['body']['ko'], 'database 평문 body 를 재활용해야 한다(HTML 아님).'); + } + + public function test_database가_없으면_mail_body의_htm_l을_제거해_쓴다(): void + { + $def = [ + 'welcome' => [ + 'channels' => ['mail'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '

{name}님

환영합니다

']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $sms = collect($result['welcome']['templates'])->firstWhere('channel', 'sms'); + $this->assertSame('{name}님 환영합니다', $sms['body']['ko'], 'HTML 태그는 제거되고 변수는 보존되어야 한다.'); + } + + public function test_관리자_전용_알림은_증강되지_않는다(): void + { + $def = [ + 'balance_low' => [ + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'role', 'value' => 'admin']], 'body' => ['ko' => 'x']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $channels = $result['balance_low']['channels']; + $this->assertNotContains('sms', $channels, '관리자 전용 알림에는 문자/알림톡을 추가하지 않는다.'); + $this->assertNotContains('alimtalk', $channels); + } + + public function test_모듈형_순차배열도_증강한다(): void + { + // 모듈: getNotificationDefinitions() 는 순차 배열 [['type'=>..., ...], ...] + $def = [ + [ + 'type' => 'order_confirmed', + 'channels' => ['mail', 'database'], + 'templates' => [ + ['channel' => 'database', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '주문 완료']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $this->assertContains('alimtalk', $result[0]['channels']); + $this->assertSame('order_confirmed', $result[0]['type'], 'type 등 기존 필드는 보존되어야 한다.'); + } + + public function test_이미_alimtalk이_있으면_중복_추가하지_않는다(): void + { + $def = [ + 'welcome' => [ + 'channels' => ['mail', 'alimtalk'], + 'templates' => [ + ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => 'x']], + ['channel' => 'alimtalk', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '기존']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $alimtalkCount = count(array_filter($result['welcome']['templates'], fn ($t) => $t['channel'] === 'alimtalk')); + $this->assertSame(1, $alimtalkCount, 'alimtalk template 이 중복 추가되면 안 된다.'); + } + + public function test_recipients가_없는_알림은_회원_대상으로_보고_증강한다(): void + { + $def = [ + 'generic' => [ + 'channels' => ['mail'], + 'templates' => [ + ['channel' => 'mail', 'body' => ['ko' => '알림']], + ], + ], + ]; + + $result = $this->listener()->augment($def); + + $this->assertContains('alimtalk', $result['generic']['channels']); + // recipients 미지정 → 기본 trigger_user + $alimtalk = collect($result['generic']['templates'])->firstWhere('channel', 'alimtalk'); + $this->assertSame('trigger_user', $alimtalk['recipients'][0]['type']); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php new file mode 100644 index 00000000..e45adb08 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php @@ -0,0 +1,306 @@ + $rendered replaceVariables 반환값 + */ + private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '{name} 님 주문이 완료되었습니다.']): NotificationTemplate + { + $template = Mockery::mock(NotificationTemplate::class)->makePartial(); + $template->is_active = true; + $template->shouldReceive('replaceVariables')->andReturn($rendered); + + return $template; + } + + /** + * findActive 반환값을 지정한 binding 리포지토리 mock 을 만듭니다. + * + * @param BizppurioNotificationBinding|null $binding findActive 반환값 + */ + private function fakeBindings(?BizppurioNotificationBinding $binding): BizppurioNotificationBindingRepositoryInterface + { + $repo = Mockery::mock(BizppurioNotificationBindingRepositoryInterface::class); + $repo->shouldReceive('findActive') + ->with(Mockery::any(), 'alimtalk') + ->andReturn($binding); + + return $repo; + } + + /** + * 연결(binding) 모델을 만듭니다. + */ + private function binding(bool $fallback = false): BizppurioNotificationBinding + { + $binding = new BizppurioNotificationBinding; + $binding->notification_type = 'order_confirmed'; + $binding->channel = 'alimtalk'; + $binding->template_code = 'TW_1234'; + $binding->template_name = '주문완료'; + $binding->fallback_sms_enabled = $fallback; + $binding->is_active = true; + + return $binding; + } + + /** + * 템플릿·binding·빌더를 조합한 driver 를 만듭니다. + */ + private function makeDriver( + ?NotificationTemplate $template, + ?BizppurioNotificationBinding $binding, + ?MessagePayloadBuilder $builder = null, + ): AlimtalkChannelDriver { + $templateService = Mockery::mock(NotificationTemplateService::class); + $templateService->shouldReceive('resolve') + ->with(Mockery::any(), 'alimtalk') + ->andReturn($template); + + return new AlimtalkChannelDriver( + $templateService, + $this->fakeBindings($binding), + $builder ?? $this->spyBuilder(), + new BizppurioDispatchRepository, + ); + } + + /** + * buildAlimtalk 호출 인자를 기록하는 payload 빌더 스파이를 만듭니다. + */ + private function spyBuilder(): MessagePayloadBuilder + { + return new class extends MessagePayloadBuilder + { + /** @var array> */ + public array $calls = []; + + public function __construct() {} + + public function buildAlimtalk( + string $to, + string $templateCode, + string $message, + string $refkey, + array $extra = [], + ): array { + $this->calls[] = [ + 'to' => $to, + 'templateCode' => $templateCode, + 'message' => $message, + 'refkey' => $refkey, + ]; + + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'templatecode' => $templateCode]; + } + }; + } + + private function notification(array $data = ['name' => '김철수']): GenericNotification + { + return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce'); + } + + public function test_연결된_템플릿이_없으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // binding = null → 미연결 → skip + $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification()); + + Bus::assertNotDispatched(SendMessageJob::class); + $this->assertDatabaseCount('bizppurio_dispatches', 0); + } + + public function test_alimtalk_템플릿이_없으면_발송하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + + // binding 은 있으나 코어 alimtalk 본문 템플릿이 없음 → skip + $this->makeDriver(null, $this->binding())->send($member, $this->notification()); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_회원은_mobile로_연결된_템플릿코드로_발송한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678']); + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) + ->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertCount(1, $builder->calls); + $this->assertSame('01012345678', $builder->calls[0]['to']); + $this->assertSame('TW_1234', $builder->calls[0]['templateCode'], '연결된 카카오 템플릿 코드로 발송해야 한다.'); + } + + public function test_비회원은_data의_전화번호로_발송한다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + $data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000']; + $builder = $this->spyBuilder(); + + $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder) + ->send($guest, $this->notification($data)); + + Bus::assertDispatched(SendMessageJob::class); + $this->assertSame('01099990000', $builder->calls[0]['to']); + } + + public function test_본문_변수를_알림톡_형식으로_변환한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + $builder = $this->spyBuilder(); + + $this->makeDriver( + $this->fakeTemplate(['subject' => '', 'body' => '{name} 님 {order_number} 주문 완료']), + $this->binding(), + $builder, + )->send($member, $this->notification()); + + $this->assertSame( + '#{name} 님 #{order_number} 주문 완료', + $builder->calls[0]['message'], + '코어 {var} 형식을 알림톡 #{var} 형식으로 치환해야 한다.', + ); + } + + public function test_이미_알림톡_형식인_변수는_중복_변환하지_않는다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + $builder = $this->spyBuilder(); + + $this->makeDriver( + $this->fakeTemplate(['subject' => '', 'body' => '#{name} 님 {order_number}']), + $this->binding(), + $builder, + )->send($member, $this->notification()); + + $this->assertSame( + '#{name} 님 #{order_number}', + $builder->calls[0]['message'], + '이미 #{var} 인 변수는 ##{var} 로 이중 변환되면 안 된다.', + ); + } + + public function test_대체발송_o_n이면_payload에_sms_resend가_병합된다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + // 실제 payload 병합을 관찰하려면 실 빌더가 필요하므로, buildAlimtalk 만 최소 stub 하고 + // withSmsFallback 결과를 dispatch 된 Job payload 로 검증한다. + $builder = new class extends MessagePayloadBuilder + { + public function __construct() {} + + public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array + { + return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => ['message' => $message]]]; + } + }; + + // replaceVariables 는 치환 완료본을 반환하므로 mock body 도 치환 완료 텍스트로 준다. + // 드라이버는 이 body 를 알림톡 본문(#{var} 변환)과 대체 SMS 본문(원문 그대로) 두 곳에 쓴다. + $this->makeDriver( + $this->fakeTemplate(['subject' => '', 'body' => '김철수 님 주문 완료']), + $this->binding(fallback: true), + $builder, + )->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { + $this->assertSame(['first' => 'sms'], $job->payload['resend'] ?? null, '대체발송 ON 은 resend:{first:sms} 를 넣어야 한다.'); + $this->assertArrayHasKey('recontent', $job->payload); + $this->assertSame('김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 치환 완료된 코어 본문(#{var} 미변환)이어야 한다.'); + + return true; + }); + } + + public function test_대체발송_of_f이면_resend가_없다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->fakeTemplate(), $this->binding(fallback: false)) + ->send($member, $this->notification()); + + Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) { + $this->assertArrayNotHasKey('resend', $job->payload); + + return true; + }); + } + + public function test_회원_발송_시_pending_이력을_alimtalk_채널로_생성한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']); + + $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification()); + + $this->assertDatabaseCount('bizppurio_dispatches', 1); + $dispatch = BizppurioDispatch::first(); + $this->assertSame('pending', $dispatch->status->value); + $this->assertSame('alimtalk', $dispatch->channel->value); + $this->assertSame($member->id, $dispatch->to_user_id); + $this->assertSame('order_confirmed', $dispatch->notification_type); + } + + public function test_전화번호가_없으면_발송하지_않는다(): void + { + Bus::fake(); + $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko'); + + $this->makeDriver($this->fakeTemplate(), $this->binding()) + ->send($guest, $this->notification(['name' => '홍길동'])); + + Bus::assertNotDispatched(SendMessageJob::class); + } + + public function test_generic_notification이_아니면_무시한다(): void + { + Bus::fake(); + $member = User::factory()->create(['mobile' => '01011112222']); + + $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, new Notification); + + Bus::assertNotDispatched(SendMessageJob::class); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php new file mode 100644 index 00000000..38d099df --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php @@ -0,0 +1,144 @@ +> $templates list()['templates'] 반환값 + */ + private function fakeTemplateService(array $templates): AlimtalkTemplateService + { + $service = Mockery::mock(AlimtalkTemplateService::class); + $service->shouldReceive('list')->andReturn(['templates' => $templates, 'pagination' => []]); + + return $service; + } + + private function makeService(array $templates = []): NotificationBindingService + { + return new NotificationBindingService( + new BizppurioNotificationBindingRepository, + $this->fakeTemplateService($templates), + ); + } + + public function test_all은_알림톡_연동을_type_키_맵으로_반환한다(): void + { + (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [ + 'template_code' => 'TW_1234', + 'template_name' => '주문완료', + 'fallback_sms_enabled' => true, + 'is_active' => true, + ]); + + $map = $this->makeService()->all(); + + $this->assertArrayHasKey('order_confirmed', $map); + $this->assertSame('TW_1234', $map['order_confirmed']['template_code']); + $this->assertSame('주문완료', $map['order_confirmed']['template_name']); + $this->assertTrue($map['order_confirmed']['fallback_sms_enabled']); + } + + public function test_all은_연동이_없으면_빈_맵을_반환한다(): void + { + $this->assertSame([], $this->makeService()->all()); + } + + public function test_승인_상태_템플릿만_연결_후보로_노출한다(): void + { + $service = $this->makeService([ + ['templateCode' => 'TW_A', 'templateName' => '승인됨', 'serviceStatus' => 'ACT'], + ['templateCode' => 'TW_B', 'templateName' => '발송전', 'serviceStatus' => 'RDY'], + ['templateCode' => 'TW_C', 'templateName' => '검수중', 'serviceStatus' => 'REQ'], + ['templateCode' => 'TW_D', 'templateName' => '반려', 'serviceStatus' => 'REJ'], + ]); + + $codes = array_column($service->approvedTemplates(), 'template_code'); + + $this->assertContains('TW_A', $codes, 'ACT(정상)는 노출되어야 한다.'); + $this->assertContains('TW_B', $codes, 'RDY(발송전)는 노출되어야 한다.'); + $this->assertNotContains('TW_C', $codes, 'REQ(검수중)는 제외되어야 한다.'); + $this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.'); + } + + public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void + { + $service = $this->makeService(); + + $service->bind('welcome', [ + 'template_code' => 'TW_1236', + 'template_name' => '가입환영', + 'fallback_sms_enabled' => false, + ]); + + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_1236', + 'is_active' => true, + ]); + + $service->unbind('welcome'); + + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + ]); + } + + public function test_bind는_같은_알림에_대해_갱신한다(): void + { + $service = $this->makeService(); + + $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']); + $service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']); + + $this->assertDatabaseCount('bizppurio_notification_bindings', 1); + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'template_code' => 'TW_2', + ]); + } + + public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void + { + $this->makeService()->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true); + + $this->assertDatabaseHas('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + 'template_code' => 'TW_9', + 'fallback_sms_enabled' => true, + ]); + } + + public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void + { + $service = $this->makeService(); + $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']); + + // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 + $service->applyFromTemplateSave('welcome', '', null, false); + + $this->assertDatabaseMissing('bizppurio_notification_bindings', [ + 'notification_type' => 'welcome', + 'channel' => 'alimtalk', + ]); + } +} diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml new file mode 100644 index 00000000..ff224438 --- /dev/null +++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml @@ -0,0 +1,101 @@ +# audit:allow test-scenario-coverage reason: | +# Phase 6(재설계)는 알림톡 실발송 위임(백엔드) + 시딩 필터 훅 template 증강 + 코어 편집 모달 +# 전용 칸(즉시 저장) + 상태 배너 overlay 이다. axes cross product 의 런타임 효과는 계층별로 +# 전수 가드된다: +# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/변수치환/대체발송/전화번호/이력. +# - template 증강: SeedChannelTemplatesListenerTest — 회원판정/기본문구/채널추가/멱등. +# - 연동 즉시저장·조회·권한: NotificationBindingServiceTest + NotificationBindingEndpointTest. +# - 채널 메타(is_test_mode): RegisterNotificationChannelsListenerTest. +# - overlay 전용칸·배너·i18n·무오염: notification_tab_binding.test.tsx. +# e2e_* 축(브라우저 왕복: 편집 열기→드롭다운 선택→즉시 저장→toast→배너)은 실제 카카오 발신 +# 프로필·승인 템플릿이 필요해, 계획서 §9 방침대로 PO 직접 수행하는 실연동 검증 단계에서 +# Playwright spec 을 최초 도입하며 그때 @scenario/@effects 를 이 매니페스트에 매핑한다. Vitest +# 렌더/구조 테스트가 프론트 overlay·배너·무오염을 가드하므로 이번 Phase 는 spec 미도입 +# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 커버로 충족). + +feature: 비즈뿌리오 알림톡 채널 실발송 + 알림 설정 편집 모달 연동 (Phase 6 재설계) + +description: | + 회원 대상 알림에 sms·alimtalk 채널 template 을 시딩 필터 훅으로 증강하고(코어/모듈 무수정), + 코어 알림 편집 모달(alimtalk 채널)에 전용 칸을 얹어 승인 알림톡 템플릿을 연결한다. 전용 칸 + 값은 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관해 코어 + 템플릿 무오염). 이벤트 발생 시 연결된 템플릿으로 알림톡을 자동발송한다. + + 핵심 동작: + - SeedChannelTemplatesListener: 코어/모듈 시딩 필터 훅(seed.*.notifications.translations)에 + 편승해 회원 알림에 sms·alimtalk template 증강(cleanupStaleTemplates 에도 유지). + - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. + - 변수 치환: 코어 {var} → 알림톡 #{var}(D8, 매핑 UI 없음). + - 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합. + - 전용 칸(overlay): channel==='alimtalk' 일 때만 안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 + 토글. 변경 즉시 우리 API(store) 저장 + toast. 승인(RDY/ACT) 템플릿만 드롭다운 노출. + - 상태 배너(overlay): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡테스트모드. + +axes: + channel: [alimtalk] + recipient: [member, guest] + binding_state: [bound, unbound] + fallback_sms: [on, off] + member_facing: [member_target, admin_only] + banner_state: [not_ready, test_mode, normal] + e2e_binding_action: [connect, change, unbind] + +exclusions: + - { binding_state: unbound, fallback_sms: on, reason: "미연결 알림은 대체발송 설정 자체가 없음" } + - { member_facing: admin_only, binding_state: bound, reason: "관리자 전용 알림은 증강 대상 아님 — 연동 불가" } + - { recipient: guest, e2e_binding_action: connect, reason: "연동 저장 UI 는 관리자 화면 — 수신자(member/guest)는 발송 축, UI 축과 무관" } + +effects: + # 발송 위임 (AlimtalkChannelDriver) + - alimtalk_dispatched_with_bound_template_code + - dispatch_skipped_when_no_binding + - dispatch_skipped_when_no_alimtalk_template + - dispatch_skipped_when_no_phone + - core_variables_converted_to_alimtalk_hash_form + - already_hash_form_variable_not_double_converted + - member_phone_resolved_from_mobile + - guest_phone_resolved_from_data_recipient_phone + - fallback_on_merges_sms_resend_recontent + - fallback_off_has_no_resend + - pending_dispatch_created_with_alimtalk_channel + # template 증강 (SeedChannelTemplatesListener) + - member_facing_notification_gets_sms_and_alimtalk_channels + - admin_only_notification_not_augmented + - augmented_body_reuses_database_plaintext + - augmented_body_strips_html_when_no_database + - existing_channel_not_duplicated + - module_sequential_array_augmented + # 채널 메타 (RegisterNotificationChannelsListener) + - channel_meta_carries_is_test_mode + - test_mode_off_sets_is_test_mode_false + # 연동 즉시 저장·조회·권한 (NotificationBindingService/Controller) + - store_creates_or_updates_binding_immediately + - store_empty_code_unbinds + - only_approved_templates_offered_as_options + - bindings_map_returned_for_prefill + - view_permission_required_for_read + - manage_permission_required_for_store + # 전용 칸·배너 (프론트 — Vitest 구조/i18n) + - alimtalk_section_shown_only_for_alimtalk_channel + - template_select_saves_immediately_via_our_api + - fallback_toggle_disabled_when_no_template + - save_error_shows_toast + - banner_shown_only_on_sms_alimtalk_tab_when_problem + - not_ready_banner_has_setup_action + - test_mode_banner_shown_when_ready_and_test_mode + - core_edit_modal_save_body_has_no_bizppurio_fields + # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상) + - e2e_open_alimtalk_edit_shows_section + - e2e_select_template_saves_and_toasts + - e2e_unbind_via_none_option_removes_binding + +test_files: + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php + - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php + - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx + +audit: + status: codified \ No newline at end of file