feat(message_bizppurio): 알림톡 연동 편집모달 전용칸 즉시저장 + 시딩 필터 훅 template 증강

Phase 6 재설계 — 코어 알림 설정 화면을 그대로 쓰고 알림톡 편집 시 전용 칸만 얹는다.

- 시딩 필터 훅(seed.*.notifications.translations) 편승으로 회원 알림에 sms·alimtalk
 template 증강 (코어·모듈 무수정, cleanupStaleTemplates 에도 유지)
- 코어 편집 모달에 overlay 로 전용 칸 append(연결 템플릿·SMS 대체) — 변경 즉시 저장,
 코어 저장 버튼과 무관 (코어 템플릿 무오염)
- 알림톡·SMS 탭 상태 배너(설정 미완료/테스트 모드)
- 1차 구현(우리 목록 통째 교체·게이트·우리 편집 모달) 폐기

전 계층 테스트 통과 (백엔드 170 / 프론트 63), audit 위반 0.
This commit is contained in:
chym1217 committed 2026-07-28 12:36:24 +09:00
1 parent 73b05aa42f
commit af43a7c41d
27 files changed
+2596 -85

No files matched your search

+2 -2
View File
@@ -201,7 +201,7 @@ location ~* \.(js|css|json)$ { expires max; access_log off; }
> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.
<!-- @generated:start:api-readme-extensions -->
- **확장 수**: 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 |
@@ -4,11 +4,12 @@
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
<!-- @generated:start:api-readme-index -->
- **문서 수**: 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 |
@@ -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
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index -->
@@ -114,7 +77,6 @@ Authorization: Bearer {YOUR_TOKEN}
| templateImageUrl | body | string | 아니오 | max 500 | <!-- TODO: 용도 --> |
| templatePreviewMessage | body | string | 아니오 | max 40 | <!-- TODO: 용도 --> |
| templateExtra | body | string | 아니오 | max 500 | <!-- TODO: 용도 --> |
| templateHeader | body | string | 아니오 | max 16 | <!-- TODO: 용도 --> |
| securityFlag | body | boolean | 아니오 | — | <!-- TODO: 용도 --> |
| requestInspection | body | boolean | 아니오 | — | <!-- TODO: 용도 --> |
| buttons | body | array | 아니오 | max 5 | <!-- TODO: 용도 --> |
@@ -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 | <!-- TODO: 용도 --> |
| templatePreviewMessage | body | string | 아니오 | max 40 | <!-- TODO: 용도 --> |
| templateExtra | body | string | 아니오 | max 500 | <!-- TODO: 용도 --> |
| templateHeader | body | string | 아니오 | max 16 | <!-- TODO: 용도 --> |
| securityFlag | body | boolean | 아니오 | — | <!-- TODO: 용도 --> |
| requestInspection | body | boolean | 아니오 | — | <!-- TODO: 용도 --> |
| buttons | body | array | 아니오 | max 5 | <!-- TODO: 용도 --> |
@@ -439,7 +399,6 @@ Content-Type: application/json
"templateImageUrl": "https://example.com",
"templatePreviewMessage": "예시값",
"templateExtra": "예시값",
"templateHeader": "예시값",
"securityFlag": true,
"requestInspection": true,
"buttons": [
@@ -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
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index -->
- **라우트명**: `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 | `[]` | <!-- TODO: 설명 --> |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "messages.success",
"data": {
"bindings": []
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명**
알림 설정 화면의 알림톡 탭이 소비하는 목록이다. 코어 알림 정의 중 채널에 `alimtalk` 을 포함하는 활성 알림 전체를, 연결된 알림톡 템플릿(binding)과 조인해 반환한다. 미연결 알림은 `is_bound=false` 이고 `template_code`/`template_name` 은 null, `fallback_sms_enabled` 는 false 다. `variables` 는 코어 알림 정의의 변수 목록으로, 편집 모달의 "제공 변수" 안내에 쓰인다.
### POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store -->
- **라우트명**: `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 | <!-- TODO: 용도 --> |
| template_code | body | string | 아니오 | max 30 | <!-- TODO: 용도 --> |
| template_name | body | string | 아니오 | max 255 | template 이름 (식별자) |
| fallback_sms_enabled | body | boolean | 아니오 | — | <!-- TODO: 용도 --> |
**요청 예시**
```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":"…` | <!-- TODO: 설명 --> |
**응답 예시**
```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` 에 필드별 메시지) |
<!-- @generated:end -->
**설명**
알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 승인되지 않은 템플릿 코드도 저장 자체는 가능하나(운영자 선택 자유), 편집 모달 드롭다운은 승인(RDY/ACT) 템플릿만 노출해 실수를 방지한다.
### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates
<!-- @generated:start: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` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
<!-- @generated:end -->
**설명**
연동 편집 모달의 "연결 템플릿" 드롭다운을 채우는 소스다. 카카오 템플릿 목록 중 serviceStatus 가 RDY(발송전)·ACT(정상)인 승인 템플릿만 반환한다. 자격증명(bizId·apiKey·senderKey) 미설정이거나 kapi 조회 실패 시 422 로 카카오 사유를 그대로 전달한다.
@@ -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.',
],
];
@@ -124,4 +124,10 @@ return [
'image_url' => '이미지 링크',
],
],
// 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답)
'binding' => [
'saved' => '알림톡 연동을 저장했습니다.',
'removed' => '알림톡 연동을 해제했습니다.',
],
];
@@ -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<class-string>
*/
@@ -304,6 +337,7 @@ class Plugin extends AbstractPlugin
{
return [
RegisterNotificationChannelsListener::class,
SeedChannelTemplatesListener::class,
GuestPhoneExtractListener::class,
ValidateBizppurioSettingsListener::class,
];
@@ -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
}
@@ -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<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[seg], root);
for (const key of keys) {
expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
}
});
});
@@ -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.",
@@ -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": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.",
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Concerns;
use App\Helpers\ResponseHelper;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 카카오 관리 API(kapi) 위임 액션을 공통 래핑하는 트레이트.
*
* kapi 실패(자격증명 미설정·반려·차단 등)는 BizppurioApiException 으로 전달되므로, 이를 catch
* 하여 카카오가 준 실패 사유(message)와 결과 코드를 그대로 422 로 반환한다. 운영자가 반려/차단
* 사유를 화면에서 바로 확인할 수 있게 한다. 알림톡 템플릿 관리(Phase 5)와 연동(Phase 6)
* 컨트롤러가 공유한다.
*/
trait GuardsKakaoRequests
{
/**
* kapi 위임 콜백을 실행하고 실패 시 카카오 사유를 422 로 반환합니다.
*
* @param callable():JsonResponse $callback kapi 위임 액션
* @return JsonResponse 성공 응답 또는 카카오 사유가 담긴 422
*/
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(),
],
);
}
}
}
@@ -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 트레이트로 이관(연동 컨트롤러와 공유).
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreNotificationBindingRequest;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
/**
* 알림↔알림톡 템플릿 연동 컨트롤러 (계획서 §6-2, Phase 6 재설계).
*
* 알림 설정 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS
* 대체 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이
* 컨트롤러로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼과 무관해
* 코어 템플릿 무오염). 코어 편집 모달·저장 버튼은 전혀 건드리지 않는다.
*
* - index/all: 현재 알림톡 연동 맵(전용 칸 프리필)
* - approvedTemplates: 연결 가능(승인) 템플릿 드롭다운 옵션
* - store: 연결 템플릿·SMS 대체 즉시 저장(빈 코드=해제)
*
* 권한(라우트 미들웨어): 조회 = messaging.view, 저장 = messaging.manage
*/
class NotificationBindingController extends AdminBaseController
{
use GuardsKakaoRequests;
/**
* @param NotificationBindingService $service 연동 서비스
*/
public function __construct(
private readonly NotificationBindingService $service,
) {
parent::__construct();
}
/**
* 알림톡 채널의 현재 연동 맵을 반환합니다 (전용 칸 프리필).
*
* notification_type → 연동 정보(연결 템플릿·SMS 대체) 맵. 전용 칸이 편집 대상 알림의
* type 으로 조회해 드롭다운·토글 초기값을 채운다.
*
* @return JsonResponse data.bindings 에 notification_type 키의 연동 맵
*/
public function index(): JsonResponse
{
return ResponseHelper::success('messages.success', [
'bindings' => $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(),
]);
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 알림톡 연동 즉시 저장 검증 (계획서 §6-2, Phase 6).
*
* 코어 편집 모달 전용 칸에서 연결 템플릿·SMS 대체를 바꾸면 즉시 이 요청으로 저장된다.
* template_code 는 nullable — 비어 있으면 연동 해제로 처리한다("연결 안 함" 선택).
*/
class StoreNotificationBindingRequest extends FormRequest
{
/**
* 권한은 라우트 미들웨어(messaging.manage)에서 처리한다.
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array<string, mixed>
*/
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'],
];
}
}
@@ -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 와 함께 반환합니다.
*
@@ -0,0 +1,260 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* 회원 대상 알림 정의에 sms·alimtalk 채널 template 을 증강하는 시딩 필터 리스너
* (계획서 §6-2 결정 D, Phase 6 재설계).
*
* 코어/모듈은 알림 정의를 DB 로 시딩하기 직전에 "정의 배열"을 필터 훅으로 한 번 통과시킨다
* (언어팩 다국어 병합용). 이 리스너가 그 훅에 편승해, 각 정의 배열에 sms·alimtalk 채널을
* channels 목록과 templates 에 끼워넣는다. 그러면 코어/모듈이 그것을 자기 선언처럼 저장하고
* (NotificationSyncHelper::syncTemplate), cleanupStaleTemplates 의 definedChannels 에 우리
* 채널이 포함되므로 재시딩·업데이트에도 삭제되지 않는다. 코어·모듈 파일은 수정하지 않는다.
*
* 구독 훅(3영역, 정의 배열을 넘기는 필터):
* - seed.notifications.translations (코어 — config/core.php)
* - seed.sirsoft-board.notifications.translations (게시판)
* - seed.sirsoft-ecommerce.notifications.translations (이커머스)
*
* 배열 형태 차이:
* - 코어: 연관 배열 `['welcome' => [...], ...]` (키가 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<string, array<string, mixed>>
*/
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<int|string, mixed> $definitions 시딩 직전 알림 정의 배열
* @return array<int|string, mixed> 채널이 증강된 정의 배열
*/
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<string, mixed> $definition 단일 알림 정의
* @return array<string, mixed> 증강된 정의
*/
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<string, mixed> $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<int, array<string, mixed>> $templates 기존 template 목록
* @return array<string, string> 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<int, array<string, mixed>> $templates 기존 template 목록
* @return array<string, string> 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<int, array<string, mixed>> $templates 기존 template 목록
* @return array<int, array<string, mixed>> 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<int, array<string, mixed>> $templates template 목록
* @param string $channel 찾을 채널
* @return array<string, mixed>|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));
}
}
@@ -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));
}
}
@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Models\User;
use App\Notifications\BaseNotification;
use App\Notifications\GenericNotification;
use App\Services\NotificationTemplateService;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioDispatchRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
/**
* 코어 알림 시스템의 alimtalk 채널 드라이버 (계획서 §6-2·Phase 6).
*
* ChannelManager::extend('alimtalk', …)(ServiceProvider::boot)로 등록되어, 코어가
* `via()` 에서 'alimtalk' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Phase 3 까지는
* no-op 스텁이 등록돼 있었고(크래시 방지), 이 드라이버가 그 스텁을 실발송으로 교체한다.
*
* SmsChannelDriver 와 흐름은 같으나 핵심 차이는 "발송 대상 템플릿"의 출처다:
* - SMS: 코어 알림 템플릿(sms 채널) 본문을 그대로 발송한다.
* - 알림톡: 코어 알림 템플릿(alimtalk 채널) 본문을 렌더한 뒤, 관리자가 알림톡 탭에서 연결한
* 카카오 승인 템플릿(bizppurio_notification_bindings.template_code)으로 발송한다.
* 연결(binding)이 없으면 발송하지 않는다(알림톡은 임의 본문 발송 불가 — 승인 템플릿 필수).
*
* 처리 흐름:
* 1. 알림 유형(type)으로 활성 binding 조회. 없으면 skip(연결 안 된 알림은 알림톡 미발송).
* 2. 코어 alimtalk 템플릿 본문 렌더(변수 치환) → 알림톡 변수 형식({x} → #{x})으로 변환.
* 3. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약).
* 4. refkey 생성 → payload 조립(대체발송 ON 시 resend/recontent 병합) → 이력 pending →
* SendMessageJob 위임.
*
* 고아 template_code(연결한 카카오 템플릿이 삭제·차단됨)는 발송 자체는 수행하고, 비즈뿌리오
* 결과코드(7106/7107/7206 등)로 실패 처리된다(webhook·Job 이 이력에 기록 — Phase 4).
*/
class AlimtalkChannelDriver
{
/** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (SmsChannelDriver 와 동일 계약) */
public const RECIPIENT_PHONE_KEY = '_recipient_phone';
/**
* @param NotificationTemplateService $templateService alimtalk 채널 본문 템플릿 resolve
* @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화
*/
public function __construct(
private readonly NotificationTemplateService $templateService,
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly MessagePayloadBuilder $payloadBuilder,
private readonly BizppurioDispatchRepositoryInterface $dispatches,
) {}
/**
* 알림을 카카오 알림톡으로 발송합니다.
*
* Laravel NotificationSender 가 'alimtalk' 채널 드라이버로 이 메서드를 호출한다.
* GenericNotification 이 아닌 알림은 대상이 아니므로 조용히 무시한다.
*
* @param object $notifiable 수신자 (User 또는 GuestNotifiable)
* @param Notification $notification 발송 대상 알림
*/
public function send(object $notifiable, Notification $notification): void
{
if (! $notification instanceof GenericNotification) {
return;
}
$type = $notification->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('/(?<!#)\{([^{}]+)\}/', '#{$1}', $body);
}
/**
* 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송, 계획서 §6-2).
*
* 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. 대체 SMS 본문은
* 코어 알림 본문(원문 {var} 형식이 아니라 실제 치환 완료 텍스트)을 재사용한다. 부록 C-2 의
* `resend:{first:"sms"}` + `recontent:{sms:{message}}` 구조를 따른다.
*
* @param array<string, mixed> $payload 알림톡 발송 payload
* @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용)
* @return array<string, mixed> 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<string, mixed> $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);
}
}
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
/**
* 이벤트↔알림톡 템플릿 연동(binding) 서비스 (계획서 §6-2, Phase 6 재설계 A).
*
* 알림 설정 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 사용한다(⚑⚑ 결정 A). 연결 템플릿·
* SMS 대체는 코어 편집 모달에 얹은 전용 칸에서 입력하고, 코어 [저장] 훅
* (core.notification_template.filter_update_data/after_update)에 편승해 이 서비스의
* bind/unbind 로 저장된다(별도 저장 버튼·우리 목록 API 없음).
*
* 이 서비스는 (1) 코어 편집 모달 전용 칸이 소비하는 조회 — 승인 템플릿 드롭다운(approvedTemplates)
* 과 현재 연동 맵(all) — 및 (2) 저장 훅 리스너가 호출하는 bind/unbind 를 제공한다.
*
* 연결 대상 템플릿 드롭다운은 "발송 가능(승인) 상태" 템플릿만 노출한다 — 미승인 템플릿에
* 연동해도 발송이 거부되기 때문. 승인 판정은 AlimtalkTemplateService 의 배지 매핑(RDY/ACT
* = 발송가능)을 재사용한다.
*/
class NotificationBindingService
{
/** 연동 대상 채널 (1차 알림톡 고정) */
private const CHANNEL = 'alimtalk';
/** 발송 가능(연결 허용) 카카오 템플릿 상태 — RDY(발송전)·ACT(정상) */
private const SENDABLE_STATUSES = ['RDY', 'ACT'];
/**
* @param BizppurioNotificationBindingRepositoryInterface $bindings 연동 조회/저장
* @param AlimtalkTemplateService $templates 카카오 승인 템플릿 조회
*/
public function __construct(
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly AlimtalkTemplateService $templates,
) {}
/**
* 알림톡 채널의 모든 연동을 notification_type → 연동 정보 맵으로 반환합니다.
*
* 코어 편집 모달 전용 칸이 편집 중인 알림의 기존 연동(연결 템플릿·SMS 대체)을 프리필하는
* 데 쓴다. 코어 목록은 코어가 렌더하므로(⚑⚑ 결정 A) 알림 정의와 조인하지 않고 binding
* 만 내려준다. 편집 모달은 def.type 으로 이 맵을 조회한다.
*
* @return array<string, array<string, mixed>> 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<int, array{template_code: string, template_name: string}>
*
* @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<string, mixed> $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,
]);
}
}
@@ -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');
});
});
});
@@ -0,0 +1,191 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Notification;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 알림↔알림톡 템플릿 연동 조회 엔드포인트 테스트 (Phase 6 재설계 A, §6-2).
*
* 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다. 이 엔드포인트는 코어 편집 모달 전용 칸이
* 소비하는 조회(연동 맵 index·승인 템플릿 드롭다운 approved-templates)와 즉시 저장(store)을
* 제공한다. 값 변경 즉시 store 로 저장되며(빈 코드=해제), 코어 저장 버튼과 무관하다(무오염).
* 조회는 messaging.view, 저장은 messaging.manage 권한을 요구한다(라우트 미들웨어).
*/
class NotificationBindingEndpointTest extends PluginTestCase
{
private const BASE = '/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings';
/**
* 지정 권한 식별자들을 가진 admin 사용자를 만듭니다.
*
* @param array<int, string> $permissionIds 부여할 권한 식별자
*/
private function adminWith(array $permissionIds): User
{
$user = User::factory()->create();
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
);
$permIds = [];
foreach ($permissionIds as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
);
$permIds[] = $permission->id;
}
$testRole = Role::create([
'identifier' => 'bizppurio_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);
}
}
@@ -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
@@ -0,0 +1,149 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Listeners;
use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* SeedChannelTemplatesListener — 시딩 필터 훅으로 회원 알림에 sms·alimtalk template 증강 검증.
*
* 코어/모듈이 알림 정의를 시딩하기 직전에 통과하는 정의 배열에 대해, 회원 대상 알림에만
* sms·alimtalk 채널 template 을 channels·templates 에 추가한다(결정 D·E). 관리자 전용 알림은
* 건너뛰고, 기본 body 는 database 채널 body 를 재활용한다.
*/
class SeedChannelTemplatesListenerTest extends PluginTestCase
{
private function listener(): SeedChannelTemplatesListener
{
return new SeedChannelTemplatesListener;
}
/** 코어형 연관 배열(키=type) 한 건 정의를 만듭니다. */
private function coreDefinition(array $recipients = [['type' => 'trigger_user']]): array
{
return [
'welcome' => [
'hook_prefix' => 'core.auth',
'channels' => ['mail', 'database'],
'templates' => [
['channel' => 'mail', 'recipients' => $recipients, 'subject' => ['ko' => '제목'], 'body' => ['ko' => '<h1>{name}</h1>']],
['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' => '<h1>{name}님</h1><p>환영합니다</p>']],
],
],
];
$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']);
}
}
@@ -0,0 +1,306 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Models\NotificationTemplate;
use App\Models\User;
use App\Notifications\GenericNotification;
use App\Notifications\GuestNotifiable;
use App\Services\NotificationTemplateService;
use Illuminate\Notifications\Notification;
use Illuminate\Support\Facades\Bus;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Jobs\SendMessageJob;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioNotificationBinding;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioDispatchRepository;
use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificationBindingRepositoryInterface;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* AlimtalkChannelDriver — binding 게이트·변수치환({x}→#{x})·대체발송·Job 위임 검증.
*
* 알림톡은 SMS 와 달리 "관리자가 연결한 승인 템플릿(binding)"이 있을 때만 발송된다.
* 발송 Job dispatch 는 Bus::fake 로 관찰한다(실제 발송은 SendMessageJobTest 가 커버).
*/
class AlimtalkChannelDriverTest extends PluginTestCase
{
/**
* 렌더 결과를 반환하는 NotificationTemplate mock 을 만듭니다.
*
* @param array<string, string> $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<int, array<string, mixed>> */
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);
}
}
@@ -0,0 +1,144 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Repositories\BizppurioNotificationBindingRepository;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* NotificationBindingService — 연동 조회(all·approvedTemplates)·저장(bind·unbind·applyFromTemplateSave) 검증.
*
* Phase 6 재설계 A: 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다. 이 서비스는 코어 편집
* 모달 전용 칸이 소비하는 조회(현재 연동 맵·승인 템플릿 드롭다운)와 코어 [저장] 훅이 호출하는
* bind/unbind 를 제공한다. (회원 대상 판정·template 증강은 SeedChannelTemplatesListener 로 이동.)
*/
class NotificationBindingServiceTest extends PluginTestCase
{
/**
* kapi 목록 반환값을 지정한 AlimtalkTemplateService mock 을 만듭니다.
*
* @param array<int, array<string, mixed>> $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',
]);
}
}
@@ -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