feat(message_bizppurio): 알림톡 카카오 템플릿 내용 발송 반영 + 연결 소실 감지

- 알림톡 발송이 카카오 승인 템플릿의 본문·버튼·요소를 실제로 채워 보내도록 구현(B안):
 카카오 상세조회 + template_code 단위 캐시(기본 60분, 수동 초기화·끄기 지원),
 카카오→발송 형식 변환 + 변수 치환
- 결과코드 재시도 분류 정정(알림톡 일시오류 7306/7307/7421/7437 추가)
- 발신프로필 응답 파싱 정정(data.success[])
- 연결한 카카오 템플릿 소실(삭제·차단·미승인) 시 알림 설정에 "사용 불가" 표시,
 조회 실패/승인 없음 구분 안내(결함 2·3)
This commit is contained in:
chym1217
2026-07-28 12:40:14 +09:00
parent ca5c9a3438
commit d4bb117b9b
34 changed files with 1690 additions and 92 deletions
@@ -25,3 +25,8 @@
- 목록 상단에 배지 의미(발송가능·검수중·미검수·반려)와 함께, 템플릿 등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서 한다는 안내와 콘솔 바로가기를 제공합니다.
- 환경설정 탭에 카카오 알림톡·문자(SMS/LMS) 사용 전 준비 사항(비즈뿌리오 가입·API 승인·발신프로필 등록·발신번호 사전 등록)을 안내하는 박스를 추가했습니다.
- 템플릿 목록·상세 조회가 실패하면 카카오가 알려준 실제 사유(예: 접근 불가 IP, 발신프로필 미설정 등)를 화면에 그대로 표시합니다.
- 알림에 연결한 카카오 알림톡 템플릿을 실제로 발송합니다. 카카오에 등록·승인된 템플릿의 본문뿐 아니라 버튼·바로연결·강조표기·아이템리스트·대표링크까지 그대로 발송에 반영하며, 각 항목의 변수는 발송 시점의 실제 데이터로 채워집니다. 버튼이나 링크가 포함된 템플릿도 정상적으로 발송됩니다.
- 환경설정에 "알림톡 내용 캐시 시간(분)"과 "캐시 초기화" 버튼을 추가했습니다. 카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용하며(기본 60분, 0으로 두면 발송할 때마다 최신 내용을 가져옵니다), 카카오에서 템플릿을 방금 수정한 경우 [캐시 초기화]로 다음 발송부터 최신 내용이 즉시 반영되도록 할 수 있습니다.
- 알림톡 발송이 일시적인 오류(카카오 시스템 오류·처리 지연·타임아웃 등)로 실패한 경우 자동으로 다시 시도합니다.
- 알림에 연결한 알림톡 템플릿이 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요" 표시가 나타납니다. 예전에는 연결이 끊긴 뒤에도 정상 연결처럼 보여 발송 실패 후에야 알 수 있었지만, 이제 화면에서 바로 확인하고 다시 연결할 수 있습니다. (카카오 조회가 일시적으로 안 되는 경우에는 잘못된 경고가 뜨지 않도록 표시를 보류합니다.)
- 알림톡 연결 창에서 자격증명 오류 등으로 승인 템플릿 목록을 불러오지 못한 경우와, 승인된 템플릿이 실제로 없는 경우를 구분해 안내합니다. 불러오기에 실패하면 설정을 확인하라는 안내가, 실제로 없으면 템플릿을 먼저 등록·검수하라는 안내가 표시됩니다.
@@ -176,3 +176,49 @@ Authorization: Bearer {YOUR_TOKEN}
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cache.clear`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@clearCache`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
| 필드 | 타입 | 설명 |
| --- | --- | --- |
| `cleared` | integer | 초기화한 캐시 키 수(연결된 고유 알림톡 템플릿 코드 수) |
**응답 예시**
```json
{
"success": true,
"message": "알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
"data": { "cleared": 3 }
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
**설명**
발송 시 알림톡은 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 카카오 상세조회로 가져와 채우며, 그 결과를 template_code 단위로 캐시한다(기본 1시간, 환경설정 `template_cache_ttl` 로 조정, 0이면 캐시 끔). 카카오 콘솔에서 템플릿 내용을 방금 변경해 캐시 만료를 기다리지 않고 즉시 반영하고 싶을 때 이 엔드포인트로 캐시를 비운다. 연결(binding)된 모든 알림톡 템플릿의 캐시를 초기화하며, 다음 발송에서 최신 내용으로 재조회된다. 카카오 API 를 호출하지 않고 로컬 캐시만 비우므로 rate limit 에 영향을 주지 않는다. 관리자 화면(알림톡 템플릿 탭)의 "내용 캐시 초기화" 버튼이 이 엔드포인트를 호출한다.
@@ -81,4 +81,9 @@ return [
'saved' => 'Alimtalk binding saved.',
'removed' => 'Alimtalk binding removed.',
],
// Dispatch template content cache (AlimtalkTemplateController::clearCache response)
'cache' => [
'cleared' => 'Alimtalk template content cache cleared. The latest content will apply from the next dispatch.',
],
];
@@ -58,9 +58,13 @@ return [
'7107' => 'Blocked sender key',
'7204' => 'Template mismatch',
'7206' => 'Unapproved template',
'7306' => 'Kakao system error',
'7307' => 'Processing delayed',
'7308' => 'Phone number error',
'7320' => 'Receiver blocked',
'7325' => 'Variable length exceeded',
'7421' => 'Timeout',
'7436' => 'Insufficient wallet balance (Alimtalk)',
'7437' => 'Message request failed',
'7523' => 'Other error',
];
@@ -81,4 +81,9 @@ return [
'saved' => '알림톡 연동을 저장했습니다.',
'removed' => '알림톡 연동을 해제했습니다.',
],
// 발송용 템플릿 내용 캐시 (AlimtalkTemplateController::clearCache 응답)
'cache' => [
'cleared' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.',
],
];
@@ -61,9 +61,13 @@ return [
'7107' => '차단된 발신 키',
'7204' => '템플릿 불일치',
'7206' => '검수되지 않은 템플릿',
'7306' => '카카오 시스템 오류',
'7307' => '처리 지연',
'7308' => '전화번호 오류',
'7320' => '수신 차단',
'7325' => '변수 길이 초과',
'7421' => '타임아웃',
'7436' => '지갑 잔액 부족(알림톡)',
'7437' => '메시지 요청 실패',
'7523' => '기타 오류',
];
@@ -247,6 +247,16 @@ class Plugin extends AbstractPlugin
'sensitive' => true,
'required' => false,
],
'template_cache_minutes' => [
'type' => 'number',
'default' => 60,
'label' => ['ko' => '알림톡 내용 캐시 시간(분)', 'en' => 'Alimtalk content cache (minutes)'],
'hint' => [
'ko' => '카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용합니다(기본 60분). 이 시간이 지나면 다음 발송 때 최신 내용을 다시 가져옵니다. 0으로 두면 매번 최신 내용을 가져옵니다 — 발송이 많으면 조회 제한에 걸릴 수 있어 권장하지 않습니다. 카카오에서 템플릿을 방금 수정했다면 아래 [캐시 초기화]로 즉시 반영할 수 있습니다.',
'en' => 'Reuses Kakao alimtalk template content for this period (default 60 minutes). After it expires, the latest content is fetched on the next dispatch. Set to 0 to always fetch the latest — not recommended for high volume as it may hit rate limits. If you just edited a template in Kakao, use [Clear cache] below to apply it immediately.',
],
'required' => false,
],
];
}
@@ -267,6 +277,7 @@ class Plugin extends AbstractPlugin
'api_key' => '',
'sender_number' => '',
'sender_key' => '',
'template_cache_minutes' => 60,
];
}
@@ -37,6 +37,19 @@
},
"text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}"
},
{
"comment": "소실 경고 — 연결됨이지만 연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가(is_unavailable). 승인 목록 대조가 가능했고(=is_unavailable 필드 존재) true 일 때만 표시. 카카오 조회 실패 시 서비스가 필드를 부여하지 않아 배지도 뜨지 않는다(오탐 방지).",
"type": "basic",
"name": "Span",
"if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.is_unavailable === true}}",
"props": {
"className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 flex-shrink-0"
},
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "fas fa-triangle-exclamation", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.unavailable" }
]
},
{
"comment": "⑧ SMS 대체발송 배지 — 연결됨일 때만, 켜짐/꺼짐을 색으로 구분해 둘 다 표시(다크에서 묽지 않게 solid 색)",
"type": "basic",
@@ -92,9 +92,11 @@ describe('alimtalk templates — 목록 서브뷰 (조회 전용)', () => {
expect(raw).toContain('templateKeyword');
});
it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다', () => {
it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다 (캐시 초기화는 환경설정 탭으로 이관)', () => {
const raw = JSON.stringify(findById(root, 'templates_toolbar'));
expect(raw).toContain('templates.list.refresh');
// 캐시 초기화·캐시 시간은 환경설정 탭으로 옮겼으므로 템플릿 툴바에는 없다.
expect(raw).not.toContain('cache/clear');
// 등록 진입(폼 전환) 없음
expect(raw).not.toContain('templates.list.new');
expect(raw).not.toContain('"form"');
@@ -280,3 +282,28 @@ describe('alimtalk templates — i18n 정합', () => {
}
});
});
describe('alimtalk templates — 환경설정 탭: 발송 내용 캐시 (독립 카드)', () => {
it('발송 내용 캐시가 독립 카드로 있고 캐시 시간(분) 입력을 담는다 (form 저장 대상)', () => {
const card = findById(root, 'cache_section');
expect(card).toBeTruthy();
const raw = JSON.stringify(card);
// 카드 제목 + 분 단위 숫자 입력 + form 바인딩(name)
expect(raw).toContain('settings.cache.section_title');
expect(raw).toContain('template_cache_minutes');
expect(raw).toContain('"number"');
expect(raw).toContain('settings.fields.template_cache_minutes.label');
expect(raw).toContain('settings.fields.template_cache_minutes.hint');
});
it('같은 카드에 캐시 초기화 버튼 + 안내문이 함께 있다 (한 묶음)', () => {
const raw = JSON.stringify(findById(root, 'cache_section'));
// 즉시 캐시 비우기 — API 호출 + 성공/실패 토스트 + 상시 안내문
expect(raw).toContain('alimtalk-templates/cache/clear');
expect(raw).toContain('apiCall');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_hint');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_success');
expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_failed');
});
});
@@ -112,6 +112,15 @@ describe('binding UI — 행 연결(extension_point)', () => {
expect(raw).toContain('modal_bizppurio_binding');
});
it('연결된 카카오 템플릿이 소실(is_unavailable)이면 빨간 경고 배지를 표시한다(결함 2)', () => {
const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
// 연결됨(template_code 있음) + is_unavailable === true 일 때만 경고
expect(raw).toContain('is_unavailable === true');
expect(raw).toContain('binding.unavailable');
// 소실 경고는 red 배지로 표시(연결됨 초록과 구분)
expect(raw).toContain('bg-red-100');
});
it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => {
const row = findById(footerRoot, 'bizppurio_row_binding');
const raw = JSON.stringify(row);
@@ -65,6 +65,20 @@ describe('plugin_settings.json — 섹션', () => {
expect(findById(root, id)).toBeTruthy();
});
it('준비 안내 박스는 총괄 안내(intro)와 콘솔 링크를 구분선 위에 먼저 노출한다', () => {
const notice = findById(root, 'preparation_notice');
const raw = JSON.stringify(notice);
// 총괄 안내 문구 키 + 콘솔 링크가 존재한다
expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.intro');
expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.console_link');
// 총괄 안내가 채널별 준비 목록(sms_label)보다 먼저 배치된다 (이미지1 구조)
const introIdx = raw.indexOf('preparation.intro');
const smsIdx = raw.indexOf('preparation.sms_label');
expect(introIdx).toBeGreaterThanOrEqual(0);
expect(smsIdx).toBeGreaterThanOrEqual(0);
expect(introIdx).toBeLessThan(smsIdx);
});
it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => {
const card = findById(root, 'test_mode_card');
expect(card).toBeTruthy();
@@ -44,6 +44,14 @@
"sender_key": {
"label": "Alimtalk Sender Profile Key",
"hint": "The 40-character sender profile key used for alimtalk delivery and template lookup."
},
"template_cache_minutes": {
"label": "Alimtalk content cache (minutes)",
"hint": "Reuses Kakao template content for this period. 0 = always latest (not recommended for high volume).",
"clear_cache": "Clear cache",
"clear_cache_hint": "If you edited a template in Kakao, click to apply it immediately.",
"clear_cache_success": "Template content cache cleared. The latest content will apply from the next dispatch.",
"clear_cache_failed": "Failed to clear the cache."
}
},
"report": {
@@ -53,11 +61,16 @@
"copy": "Copy",
"copied": "Report endpoint copied to clipboard."
},
"cache": {
"section_title": "Alimtalk dispatch content cache",
"section_description": "When sending alimtalk, the template content (body and buttons) registered in Kakao must be sent as-is. To avoid requesting the content from Kakao on every dispatch, the fetched content is reused (cached) for a set period. This keeps sending fast and avoids hitting Kakao's lookup rate limit even under high volume."
},
"tabs": {
"connection": "Settings",
"templates": "Alimtalk Templates"
},
"preparation": {
"intro": "To send SMS or Kakao Alimtalk messages, first complete the preparations below in the Bizppurio console.",
"sms_label": "SMS/LMS",
"sms_sender": "Register your sender number.",
"kakao_label": "Kakao Alimtalk",
@@ -73,6 +86,7 @@
"section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.",
"modal_title": "Alimtalk Binding · {name}",
"unbound": "Not connected",
"unavailable": "Unavailable — reconnect needed",
"btn_connect": "Connect",
"btn_change": "Change",
"fallback_on": "SMS fallback ON",
@@ -44,6 +44,14 @@
"sender_key": {
"label": "알림톡 발신프로필 키",
"hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다."
},
"template_cache_minutes": {
"label": "알림톡 내용 캐시 시간(분)",
"hint": "카카오 템플릿 내용을 이 시간만큼 재사용합니다. 0 = 매번 최신(발송 많으면 비권장).",
"clear_cache": "캐시 초기화",
"clear_cache_hint": "카카오에서 템플릿을 수정했다면 눌러서 즉시 반영하세요.",
"clear_cache_success": "템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
"clear_cache_failed": "캐시 초기화에 실패했습니다."
}
},
"report": {
@@ -53,11 +61,16 @@
"copy": "복사",
"copied": "리포트 수신 주소가 클립보드에 복사되었습니다."
},
"cache": {
"section_title": "알림톡 발송 내용 캐시",
"section_description": "알림톡은 발송할 때 카카오에 등록된 템플릿 내용(본문·버튼)을 그대로 보내야 합니다. 발송할 때마다 카카오에 내용을 요청하지 않도록, 한 번 가져온 내용을 일정 시간 재사용(캐시)합니다. 덕분에 발송이 많아도 카카오 조회 제한에 걸리지 않고 빠르게 발송됩니다."
},
"tabs": {
"connection": "환경설정",
"templates": "알림톡 템플릿"
},
"preparation": {
"intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.",
"sms_label": "문자(SMS/LMS)",
"sms_sender": "발신번호를 등록하세요.",
"kakao_label": "카카오 알림톡",
@@ -73,6 +86,7 @@
"section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.",
"modal_title": "알림톡 연결 · {name}",
"unbound": "미연결",
"unavailable": "사용 불가 — 재연결 필요",
"btn_connect": "연결",
"btn_change": "연결 변경",
"fallback_on": "SMS 대체 ON",
@@ -154,37 +154,58 @@
"children": [
{
"id": "preparation_notice",
"comment": "사용 전 준비 안내(통합 1박스) — 실제 비즈뿌리오 사전 준비 절차. 박스 제목 없이 채널 그룹 2개만(문자·카카오)로 위계 단순화. 문자(발신번호 1줄) → 카카오 알림톡(채널·발신프로필 / 템플릿 등록·승인 / 고객센터 API 키 요청 3줄) 순. 각 채널은 라벨(굵게) + 들여쓴 하위 불릿. gap-4 로 그룹 간격.",
"comment": "사용 전 준비 안내(통합 1박스) — 알림톡 템플릿 탭 list_notice 와 동일 구조. 위: 총괄 안내 1줄 + 콘솔 열기 링크. 구분선(border-t). 아래: 문자·카카오 채널별 사전 준비 목록(라벨 굵게 + 들여쓴 하위 불릿).",
"type": "basic",
"name": "Div",
"props": { "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4 flex flex-col gap-4" },
"props": { "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" },
"children": [
{
"comment": "문자(SMS/LMS) — 라벨 + 발신번호 등록 1줄",
"comment": "위 — 총괄 안내 1줄 + 콘솔 열기 링크",
"type": "basic", "name": "Div",
"props": { "className": "space-y-1" },
"children": [
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] }
{
"type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.intro" }
]
},
{
"type": "basic", "name": "A",
"props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" },
"children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" },
{ "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } }
]
}
]
},
{
"comment": "카카오 알림톡 — 라벨 + 채널/템플릿/API키 3줄",
"comment": "구분선 아래 — 문자·카카오 채널별 사전 준비 목록",
"type": "basic", "name": "Div",
"props": { "className": "space-y-1" },
"props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 flex flex-col gap-4" },
"children": [
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] }
]
},
{
"type": "basic", "name": "A",
"props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 pt-1 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" },
"children": [
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" },
{ "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } }
{
"comment": "문자(SMS/LMS) — 라벨 + 발신번호 등록 1줄",
"type": "basic", "name": "Div",
"props": { "className": "space-y-1" },
"children": [
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] }
]
},
{
"comment": "카카오 알림톡 — 라벨 + 채널/템플릿/API키 3줄",
"type": "basic", "name": "Div",
"props": { "className": "space-y-1" },
"children": [
{ "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100" }, "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] },
{ "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] }
]
}
]
}
]
@@ -803,6 +824,146 @@
}
]
},
{
"id": "cache_section",
"comment": "알림톡 발송 내용 캐시 — 독립 카드. 캐시 시간(form 저장) + 캐시 초기화(즉시 API, 로컬 캐시만 비움).",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "panel-header-row" },
"children": [
{
"type": "basic",
"name": "H3",
"props": { "className": "section-heading-md" },
"text": "$t:sirsoft-message_bizppurio.settings.cache.section_title"
},
{
"type": "basic",
"name": "P",
"props": { "className": "form-hint mt-1" },
"text": "$t:sirsoft-message_bizppurio.settings.cache.section_description"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "p-6 space-y-6" },
"children": [
{
"comment": "캐시 시간(분) — 좌: 라벨+설명 / 우: 입력. 다른 설정 필드와 동일한 2단 grid.",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-4" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "form-label" },
"text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.label"
},
{
"type": "basic",
"name": "P",
"props": { "className": "form-hint" },
"text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-8 flex lg:justify-end" },
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "number",
"name": "template_cache_minutes",
"min": "0",
"autoComplete": "off",
"className": "input w-32"
}
}
]
}
]
},
{
"comment": "캐시 초기화 — 좌: 안내문 / 우: 버튼. 즉시 로컬 캐시 비우기(kapi 호출 없음).",
"type": "basic",
"name": "Div",
"props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
"children": [
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-4" },
"children": [
{
"type": "basic",
"name": "Label",
"props": { "className": "form-label" },
"text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache"
},
{
"type": "basic",
"name": "P",
"props": { "className": "form-hint" },
"text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_hint"
}
]
},
{
"type": "basic",
"name": "Div",
"props": { "className": "lg:col-span-8 flex lg:justify-end" },
"children": [
{
"type": "basic",
"name": "Button",
"props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" },
"actions": [
{
"type": "click",
"handler": "apiCall",
"auth_required": true,
"target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear",
"params": { "method": "POST" },
"onSuccess": [
{ "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_success" } }
],
"onError": [
{ "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_failed" } }
]
}
],
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "eraser", "size": "sm" } },
{ "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache" }
]
}
]
}
]
}
]
}
]
},
{
"id": "footer_buttons",
"type": "basic",
@@ -1196,7 +1357,7 @@
]
},
{
"comment": "우측 액션 그룹 — 조회 전용(새로고침만). 템플릿 등록은 비즈뿌리오 콘솔로 위임.",
"comment": "우측 액션 그룹 — 조회 전용(새로고침만). 캐시 초기화·캐시 시간은 환경설정 탭으로 이관. 템플릿 등록은 비즈뿌리오 콘솔로 위임.",
"type": "basic",
"name": "Div",
"props": { "className": "flex gap-2 items-center" },
@@ -10,6 +10,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
/**
* 알림톡 템플릿 조회 컨트롤러 (Phase 5).
@@ -30,9 +31,11 @@ class AlimtalkTemplateController extends AdminBaseController
/**
* @param AlimtalkTemplateService $service 알림톡 템플릿 서비스
* @param NotificationBindingService $bindings 연동 서비스(발송 내용 캐시 초기화 위임)
*/
public function __construct(
private readonly AlimtalkTemplateService $service,
private readonly NotificationBindingService $bindings,
) {
parent::__construct();
}
@@ -96,6 +99,24 @@ class AlimtalkTemplateController extends AdminBaseController
]));
}
/**
* 발송용 템플릿 내용 캐시를 초기화합니다 (관리자 수동 갱신).
*
* 카카오에서 템플릿 내용을 방금 변경해 캐시 만료(기본 1시간)를 기다리지 않고 즉시
* 반영하고 싶을 때 호출한다. 연결된 모든 알림톡 템플릿의 캐시를 비워, 다음 발송에서
* 최신 내용으로 재조회되게 한다. kapi 호출 없이 로컬 캐시만 비우므로 rate limit 영향 없음.
*
* @return JsonResponse data.cleared 에 초기화한 캐시 수
*/
public function clearCache(): JsonResponse
{
$cleared = $this->bindings->clearTemplateContentCache();
return ResponseHelper::success('messages.cache.cleared', [
'cleared' => $cleared,
]);
}
// kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환하는 guard() 는
// GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유).
// 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 조회 실패 원인을 즉시 파악한다.
@@ -48,8 +48,10 @@ class NotificationBindingController extends AdminBaseController
*/
public function index(): JsonResponse
{
// 알림톡 탭 진입 표시용 — 카카오 승인 목록과 대조해 소실(발송 불가) 연동을 함께 표시한다.
// 카카오 조회 실패 시 서비스가 판정을 생략하므로 이 조회가 목록 표시를 막지 않는다.
return ResponseHelper::success('messages.success', [
'bindings' => $this->service->all(),
'bindings' => $this->service->all(withAvailability: true),
]);
}
@@ -1,5 +1,7 @@
<?php
// audit:allow job-generator-needs-scale-test 단건 발송 Job(대용량 데이터 생성/처리 아님) — 결과코드 재시도 판정 집합만 변경, 대량 회귀 무관
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Jobs;
@@ -37,8 +39,14 @@ class SendMessageJob implements ShouldQueue
/** 최대 재시도 횟수 (429·일시오류 대상). sync 환경 1회 재시도 포함 */
public int $tries = 2;
/** 재시도 대상 일시 오류 결과코드 (부록 C-3) */
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013'];
/**
* 재시도 대상 일시 오류 결과코드.
*
* 공통 일시오류(5003/5004/5005/9000/3011/3013) + 알림톡 일시오류(7306 카카오 시스템오류·
* 7307 처리지연·7421 타임아웃·7437 메시지 요청실패). 7305(성공 불확실)는 중복발송 위험으로 제외.
* ResultCodeResolver::RETRYABLE_CODES 와 동일 집합을 유지한다.
*/
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'];
/** HTTP 429 재시도 시 sync 환경 최대 대기(초) */
private const SYNC_RETRY_MAX_WAIT_SECONDS = 2;
@@ -13,6 +13,7 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificatio
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTokenService;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Services\SmsChannelDriver;
use Plugins\Sirsoft\MessageBizppurio\Services\WebhookReportService;
@@ -50,6 +51,7 @@ class MessageBizppurioServiceProvider extends BasePluginServiceProvider
protected array $cacheServices = [
BizppurioTokenService::class,
WebhookReportService::class,
KakaoTemplateContentResolver::class,
];
/**
@@ -25,21 +25,27 @@ use Plugins\Sirsoft\MessageBizppurio\Repositories\Contracts\BizppurioNotificatio
* `via()` 에서 'alimtalk' 채널을 선택하면 이 드라이버의 send() 가 호출된다. Phase 3 까지는
* no-op 스텁이 등록돼 있었고(크래시 방지), 이 드라이버가 그 스텁을 실발송으로 교체한다.
*
* SmsChannelDriver 와 흐름은 같으나 핵심 차이는 "발송 대상 템플릿"의 출처다:
* SmsChannelDriver 와 흐름은 같으나 핵심 차이는 "발송 본문·요소의 출처"다:
* - SMS: 코어 알림 템플릿(sms 채널) 본문을 그대로 발송한다.
* - 알림톡: 코어 알림 템플릿(alimtalk 채널) 본문을 렌더한 뒤, 관리자가 알림톡 탭에서 연결한
* 카카오 승인 템플릿(bizppurio_notification_bindings.template_code)으로 발송한다.
* - 알림톡: 관리자가 알림톡 탭에서 연결한 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을
* 카카오 상세조회로 가져와 발송한다(B안). 비즈뿌리오 발송 API 는 완성본을 요구하므로
* templatecode 만으로는 안 되고, 카카오에 등록된 templateContent·buttons·quickReplies·
* title·header·item·itemHighlight·representLink 를 발송 형식으로 변환해 채운다.
* 연결(binding)이 없으면 발송하지 않는다(알림톡은 임의 본문 발송 불가 — 승인 템플릿 필수).
*
* 처리 흐름:
* 1. 알림 유형(type)으로 활성 binding 조회. 없으면 skip(연결 안 된 알림은 알림톡 미발송).
* 2. 코어 alimtalk 템플릿 본문 렌더(변수 치환) → 알림톡 변수 형식({x} → #{x})으로 변환.
* 2. 카카오 승인 템플릿 내용 조회(KakaoTemplateContentResolver, 캐시). 실패 시 skip.
* 3. 전화번호 해석(회원=mobile, 비회원=data 의 _recipient_phone — SmsChannelDriver 와 동일 계약).
* 4. refkey 생성 → payload 조립(대체발송 ON 시 resend/recontent 병합) → 이력 pending →
* SendMessageJob 위임.
* 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환(AlimtalkPayloadMapper). 본문 비면 skip.
* 5. refkey 생성 → payload 조립(버튼·요소는 extra, 대체발송 ON 시 resend/recontent 병합) →
* 이력 pending → SendMessageJob 위임.
*
* 고아 template_code(연결한 카카오 템플릿이 삭제·차단됨)는 발송 자체는 수행하고, 비즈뿌리오
* 결과코드(7106/7107/7206 등)로 실패 처리된다(webhook·Job 이 이력에 기록 — Phase 4).
* 카카오 조회 실패(고아 template_code·장애·rate limit)는 알림톡 발송을 skip 하고 로그만 남긴다.
* 이 단계에선 알림톡 발송 자체가 일어나지 않으므로 SMS 대체발송은 트리거되지 않는다 — SMS 대체는
* 알림톡 payload 에 resend 로 병합되어, 알림톡이 접수된 뒤 비즈뿌리오 측 발송 실패(수신 거부·미가입
* 등)에만 작동한다(fallback_sms_enabled ON 시). 발송된 알림톡의 개별 실패는 비즈뿌리오 결과코드로
* webhook·Job 이 이력에 기록한다(Phase 4).
*/
class AlimtalkChannelDriver
{
@@ -47,11 +53,13 @@ class AlimtalkChannelDriver
public const RECIPIENT_PHONE_KEY = '_recipient_phone';
/**
* @param NotificationTemplateService $templateService alimtalk 채널 본문 템플릿 resolve
* @param NotificationTemplateService $templateService SMS 대체발송 본문(코어 alimtalk 템플릿) resolve
* @param BizppurioNotificationBindingRepositoryInterface $bindings 이벤트↔템플릿 연결 조회
* @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
* @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화
* @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2)
* @param KakaoTemplateContentResolver $kakaoContent 카카오 승인 템플릿 내용 조회·캐시(B안)
* @param AlimtalkPayloadMapper $payloadMapper 카카오 내용 → 발송 형식 변환·치환(B안)
*/
public function __construct(
private readonly NotificationTemplateService $templateService,
@@ -59,6 +67,8 @@ class AlimtalkChannelDriver
private readonly MessagePayloadBuilder $payloadBuilder,
private readonly BizppurioDispatchRepositoryInterface $dispatches,
private readonly DispatchLinkContext $linkContext,
private readonly KakaoTemplateContentResolver $kakaoContent,
private readonly AlimtalkPayloadMapper $payloadMapper,
) {}
/**
@@ -86,10 +96,15 @@ class AlimtalkChannelDriver
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]);
// 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로,
// templatecode 만으로는 안 되고 카카오에 등록된 실제 내용을 가져와 채운다(B안).
// 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다.
$kakaoContent = $this->kakaoContent->resolve($binding->template_code);
if ($kakaoContent === null) {
Log::info('비즈뿌리오 알림톡 발송 skip — 카카오 템플릿 내용 조회 실패', [
'type' => $type,
'template_code' => $binding->template_code,
]);
return;
}
@@ -102,22 +117,27 @@ class AlimtalkChannelDriver
return;
}
// 4. 본문 렌더 (변수 치환) → 알림톡 변수 형식으로 변환 ({x} → #{x})
$locale = BaseNotification::resolveNotifiableLocale($notifiable);
$rendered = $template->replaceVariables($notification->getData(), $locale);
$message = $this->toAlimtalkVariables((string) ($rendered['body'] ?? ''));
// 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip.
$mapped = $this->payloadMapper->map($kakaoContent, $notification->getData());
$message = (string) ($mapped['message'] ?? '');
if (trim($message) === '') {
Log::info('비즈뿌리오 알림톡 발송 skip — 본문 비어 있음', ['type' => $type]);
return;
}
// 5. refkey 생성 → payload 조립 (대체발송 ON 시 SMS resend 병합)
// 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합)
$refkey = $this->generateRefkey();
$payload = $this->payloadBuilder->buildAlimtalk($to, $binding->template_code, $message, $refkey);
$payload = $this->payloadBuilder->buildAlimtalk(
$to,
$binding->template_code,
$message,
$refkey,
(array) ($mapped['extra'] ?? []),
);
if ($binding->fallback_sms_enabled) {
$payload = $this->withSmsFallback($payload, (string) ($rendered['body'] ?? ''));
$payload = $this->withSmsFallback($payload, $this->smsFallbackBody($type, $notifiable, $notification));
}
// 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신.
@@ -142,34 +162,48 @@ class AlimtalkChannelDriver
}
/**
* 코어 알림 본문의 변수 형식({name})을 알림톡 변수 형식(#{name})으로 변환합니다 (D8).
* SMS 대체발송 본문을 코어 alimtalk 템플릿에서 렌더합니다 (B안).
*
* 코어 알림 템플릿과 카카오 알림톡은 변수명 규칙이 동일하되 표기만 다르다. 변수명을 그대로
* 유지한 채 접두 `#` 만 붙여, 매핑 UI·컬럼 없이 자동 치환한다. 이미 `#{...}` 인 경우는
* 중복 변환하지 않는다.
* 알림톡 본문은 카카오 승인 템플릿에서 오지만, 실패 시 대체할 SMS 본문은 카카오와 무관하므로
* 코어 알림 템플릿(alimtalk 채널) 본문을 그대로 재사용한다. 코어 템플릿이 없거나 비활성이면
* 빈 문자열을 반환하고, withSmsFallback 이 빈 본문이면 대체를 병합하지 않는다(엣지 C2).
*
* @param string $body 코어 본문 (예: "{name} 님 주문 {order_number}")
* @return string 알림톡 본문 (예: "#{name} 님 주문 #{order_number}")
* @param string $type 알림 유형
* @param object $notifiable 수신자
* @param GenericNotification $notification 발송 대상 알림
* @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열)
*/
private function toAlimtalkVariables(string $body): string
private function smsFallbackBody(string $type, object $notifiable, GenericNotification $notification): string
{
// 앞에 # 가 없는 {var} 만 #{var} 로 변환 (이미 #{var} 인 경우 보존).
return (string) preg_replace('/(?<!#)\{([^{}]+)\}/', '#{$1}', $body);
$template = $this->templateService->resolve($type, DispatchChannel::Alimtalk->value);
if ($template === null || ! $template->is_active) {
return '';
}
$locale = BaseNotification::resolveNotifiableLocale($notifiable);
$rendered = $template->replaceVariables($notification->getData(), $locale);
return (string) ($rendered['body'] ?? '');
}
/**
* 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송, 계획서 §6-2).
*
* 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. 대체 SMS 본문은
* 코어 알림 본문(원문 {var} 형식이 아니라 실제 치환 완료 텍스트)을 재사용한다. 부록 C-2 의
* `resend:{first:"sms"}` + `recontent:{sms:{message}}` 구조를 따른다.
* 코어 알림 본문(치환 완료 텍스트)을 재사용한다. 부록 C-2 의 `resend:{first:"sms"}` +
* `recontent:{sms:{message}}` 구조를 따른다. 대체 본문이 비어 있으면(코어 템플릿 부재)
* 빈 SMS 를 보내지 않도록 병합하지 않는다(엣지 C2).
*
* @param array<string, mixed> $payload 알림톡 발송 payload
* @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용)
* @return array<string, mixed> resend/recontent 가 병합된 payload
* @return array<string, mixed> resend/recontent 가 병합된 payload (빈 본문이면 원본 그대로)
*/
private function withSmsFallback(array $payload, string $renderedBody): array
{
if (trim($renderedBody) === '') {
return $payload;
}
$payload['resend'] = ['first' => 'sms'];
$payload['recontent'] = ['sms' => ['message' => $renderedBody]];
@@ -0,0 +1,261 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
/**
* 카카오 상세조회 응답 → 비즈뿌리오 발송 API content.at 변환기 (B안 5-2).
*
* 카카오 관리 API(kapi) 의 템플릿 필드는 발송 API(/v3/message) 의 필드와 이름이 다르다
* (예: kapi `buttons[].linkMo` ↔ 발송 `button[].url_mobile`). 이 매퍼는 상세조회 응답을
* 발송 규격으로 변환하면서, 각 필드의 카카오 변수(#{var})를 알림 data 로 치환한다.
* 발송 API 는 완성본을 요구하므로(카카오가 변수를 대신 치환하지 않음) 이 단계가 필수다.
*
* 변환 대상(매뉴얼 5.메시지발송 알림톡 at):
* - templateContent → message
* - buttons[] → button[] (linkType→type, linkMo→url_mobile, …)
* - quickReplies[] → quickreply[]
* - templateTitle → title
* - templateHeader → header
* - templateItem → item (list/summary 구조 유지 + 치환)
* - templateItemHighlight → itemhighlight
* - templateRepresentLink → link (linkMo→url_mobile, …)
*
* 부재/빈 필드는 발송 payload 에 넣지 않는다(방어적 — 조건부 필수 필드는 템플릿에 있을 때만
* 채워야 하고, 없는 필드를 빈 값으로 넣으면 카카오가 거부한다).
*
* message 를 제외한 button/quickreply/title/header/item/itemhighlight/link 는 `extra` 키에
* 모아 반환한다. 호출측(AlimtalkChannelDriver)이 MessagePayloadBuilder::buildAlimtalk 의
* $extra 인자로 그대로 넘긴다.
*/
class AlimtalkPayloadMapper
{
/**
* 버튼/바로연결/대표링크의 카카오 링크 필드 → 발송 필드 매핑.
*
* @var array<string, string>
*/
private const LINK_FIELD_MAP = [
'linkMo' => 'url_mobile',
'linkPc' => 'url_pc',
'linkAnd' => 'scheme_android',
'linkIos' => 'scheme_ios',
];
/**
* 카카오 상세조회 응답을 발송 API content.at 형식으로 변환합니다.
*
* @param array<string, mixed> $detail 카카오 상세조회 응답(templateContent/buttons/…)
* @param array<string, mixed> $data 알림 data (변수 치환 소스: {key => value})
* @return array{message: string, extra: array<string, mixed>} 치환 완료 본문 + extra
*/
public function map(array $detail, array $data): array
{
$message = $this->substitute((string) ($detail['templateContent'] ?? ''), $data);
$extra = [];
if ($button = $this->mapButtons($detail['buttons'] ?? null, $data)) {
$extra['button'] = $button;
}
if ($quickreply = $this->mapButtons($detail['quickReplies'] ?? null, $data)) {
$extra['quickreply'] = $quickreply;
}
if ($title = $this->substitute((string) ($detail['templateTitle'] ?? ''), $data)) {
$extra['title'] = $title;
}
if ($header = $this->substitute((string) ($detail['templateHeader'] ?? ''), $data)) {
$extra['header'] = $header;
}
if ($item = $this->mapItem($detail['templateItem'] ?? null, $data)) {
$extra['item'] = $item;
}
if ($highlight = $this->mapItemHighlight($detail['templateItemHighlight'] ?? null, $data)) {
$extra['itemhighlight'] = $highlight;
}
if ($link = $this->mapLinkFields($detail['templateRepresentLink'] ?? null, $data)) {
$extra['link'] = $link;
}
return ['message' => $message, 'extra' => $extra];
}
/**
* 버튼/바로연결 배열을 발송 형식으로 변환합니다 (동일 규칙 공유).
*
* @param mixed $buttons 카카오 buttons/quickReplies 배열
* @param array<string, mixed> $data 변수 치환 소스
* @return array<int, array<string, mixed>> 발송 button/quickreply 배열 (없으면 빈 배열)
*/
private function mapButtons(mixed $buttons, array $data): array
{
if (! is_array($buttons) || $buttons === []) {
return [];
}
$mapped = [];
foreach ($buttons as $button) {
if (! is_array($button)) {
continue;
}
$row = [];
if (isset($button['name'])) {
$row['name'] = $this->substitute((string) $button['name'], $data);
}
if (isset($button['linkType'])) {
$row['type'] = (string) $button['linkType'];
}
// 링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 필드 + URL 변수 치환.
foreach (self::LINK_FIELD_MAP as $from => $to) {
if (isset($button[$from]) && $button[$from] !== '') {
$row[$to] = $this->substitute((string) $button[$from], $data);
}
}
// 전화·플러그인 등 부가 필드.
if (isset($button['telNumber']) && $button['telNumber'] !== '') {
$row['tel_number'] = (string) $button['telNumber'];
}
if (isset($button['pluginId']) && $button['pluginId'] !== '') {
$row['plugin_id'] = (string) $button['pluginId'];
}
if ($row !== []) {
$mapped[] = $row;
}
}
return $mapped;
}
/**
* 대표링크/링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 형식으로 변환합니다.
*
* @param mixed $link 카카오 templateRepresentLink
* @param array<string, mixed> $data 변수 치환 소스
* @return array<string, mixed> 발송 link 필드 (없으면 빈 배열)
*/
private function mapLinkFields(mixed $link, array $data): array
{
if (! is_array($link) || $link === []) {
return [];
}
$mapped = [];
foreach (self::LINK_FIELD_MAP as $from => $to) {
if (isset($link[$from]) && $link[$from] !== '') {
$mapped[$to] = $this->substitute((string) $link[$from], $data);
}
}
return $mapped;
}
/**
* 아이템리스트(list/summary)를 변환하고 각 필드를 치환합니다.
*
* @param mixed $item 카카오 templateItem
* @param array<string, mixed> $data 변수 치환 소스
* @return array<string, mixed> 발송 item (없으면 빈 배열)
*/
private function mapItem(mixed $item, array $data): array
{
if (! is_array($item) || $item === []) {
return [];
}
$mapped = [];
if (isset($item['list']) && is_array($item['list'])) {
$list = [];
foreach ($item['list'] as $entry) {
if (! is_array($entry)) {
continue;
}
$list[] = [
'title' => $this->substitute((string) ($entry['title'] ?? ''), $data),
'description' => $this->substitute((string) ($entry['description'] ?? ''), $data),
];
}
if ($list !== []) {
$mapped['list'] = $list;
}
}
if (isset($item['summary']) && is_array($item['summary'])) {
$mapped['summary'] = [
'title' => $this->substitute((string) ($item['summary']['title'] ?? ''), $data),
'description' => $this->substitute((string) ($item['summary']['description'] ?? ''), $data),
];
}
return $mapped;
}
/**
* 아이템 하이라이트(title/description)를 변환하고 치환합니다.
*
* @param mixed $highlight 카카오 templateItemHighlight
* @param array<string, mixed> $data 변수 치환 소스
* @return array<string, mixed> 발송 itemhighlight (없으면 빈 배열)
*/
private function mapItemHighlight(mixed $highlight, array $data): array
{
if (! is_array($highlight) || $highlight === []) {
return [];
}
$mapped = [];
if (isset($highlight['title'])) {
$mapped['title'] = $this->substitute((string) $highlight['title'], $data);
}
if (isset($highlight['description'])) {
$mapped['description'] = $this->substitute((string) $highlight['description'], $data);
}
return $mapped;
}
/**
* 카카오 변수(#{key})를 알림 data 값으로 치환합니다.
*
* 카카오 템플릿 변수와 코어 알림 data 는 변수명 규칙이 동일하다(표기만 #{} vs {}).
* data 에 없는 변수는 원문(#{key})을 유지한다 — 카카오가 변수 불일치로 판단하게 두어,
* 우리가 임의로 빈 값을 채워 잘못된 발송을 하지 않는다.
*
* @param string $text 치환 대상(#{key} 포함)
* @param array<string, mixed> $data 변수 치환 소스
* @return string 치환된 문자열
*/
private function substitute(string $text, array $data): string
{
if ($text === '' || $data === []) {
return $text;
}
$replacements = [];
foreach ($data as $key => $value) {
if (is_scalar($value) || $value === null) {
$replacements['#{'.$key.'}'] = (string) $value;
}
}
return strtr($text, $replacements);
}
}
@@ -131,9 +131,14 @@ class AlimtalkTemplateService
}
/**
* 발신프로필(사용중) 정보를 조회합니다.
* 발신프로필(사용중) 목록을 조회합니다.
*
* @return array<string, mixed> 발신프로필 응답 data
* 규격(5.발신프로필관리): `/v3/kakao/profile/use` 응답의 data 는
* `{success: [...프로필], fail: [...조회실패]}` 2단 봉투다. 실제 발신프로필 목록은
* data.success 배열에 담기므로 그 배열을 반환한다(data 통째 반환 시 success/fail
* 껍데기가 소비처에 그대로 노출됨).
*
* @return array<int, array<string, mixed>> 발신프로필 목록(data.success)
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
@@ -143,7 +148,7 @@ class AlimtalkTemplateService
$this->assertSuccess($response);
return (array) ($response['data'] ?? []);
return array_values((array) ($response['data']['success'] ?? []));
}
/**
@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\Contracts\Extension\CacheInterface;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Log;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 카카오 승인 템플릿 내용(본문·버튼·요소)을 발송용으로 조회·캐시하는 서비스 (B안 5-1).
*
* 발송 시 알림톡 payload 는 카카오에 등록된 승인 템플릿의 실제 내용(templateContent·
* buttons·quickReplies·templateTitle·templateHeader·templateItem·templateItemHighlight·
* templateRepresentLink)으로 채워야 한다(비즈뿌리오 발송 API 가 완성본을 요구 — templatecode
* 는 식별용일 뿐 내용을 대신 채워주지 않는다). 이 서비스는 그 원천을 카카오 상세조회
* (AlimtalkTemplateService::detail)로 가져오되, template_code 단위로 캐시해 rate limit
* (1000회/합산)을 억제한다.
*
* 캐시 정책:
* - TTL 은 환경설정 template_cache_ttl(기본 3600초=1시간). 만료되면 다음 조회에서 자동
* 재조회되므로 카카오 템플릿 변경이 사람 개입 없이 최대 TTL 만큼 지연돼 반영된다.
* - TTL=0 이면 캐시를 우회하고 매 조회마다 실시간 조회한다(항상 최신 — 소량 발송·최신성
* 우선 사이트용).
* - 관리자가 카카오에서 템플릿을 방금 바꿔 즉시 반영이 필요하면 clear() 로 수동 무효화한다.
*
* 조회 실패(고아 template_code·카카오 장애·rate limit)는 예외를 삼키고 null 을 반환한다.
* 호출측(AlimtalkChannelDriver)이 null 을 받으면 알림톡 발송을 skip 하고 로그만 남긴다
* (발송이 접수되지 않으므로 SMS 대체발송은 트리거되지 않는다).
*/
class KakaoTemplateContentResolver
{
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 템플릿 내용 캐시 키 접두사 (확장 도메인 캐시 접두사가 추가로 적용됨) */
private const CACHE_KEY_PREFIX = 'bizppurio:tpl_content:';
/** 템플릿 내용 캐시 시간 기본값(분) — 1시간 */
private const DEFAULT_CACHE_MINUTES = 60;
/** rate limit(429/5002) 시 재시도 횟수 — 조회 폭주 완화(1회만, 무한 방지) */
private const RATE_LIMIT_RETRIES = 1;
/** rate limit 재시도 전 대기(마이크로초) — 0.2초 */
private const RATE_LIMIT_RETRY_WAIT_US = 200000;
/**
* @param AlimtalkTemplateService $templates 카카오 상세조회 위임
* @param CacheInterface $cache 확장 도메인 캐시(contextual binding 주입)
* @param PluginSettingsService $pluginSettings 캐시 TTL 설정 조회
*/
public function __construct(
private readonly AlimtalkTemplateService $templates,
private readonly CacheInterface $cache,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 카카오 승인 템플릿의 발송용 내용을 조회합니다.
*
* TTL>0 이면 캐시 우선(없으면 조회 후 캐시), TTL=0 이면 캐시 우회(매번 조회).
* 조회 실패는 null 을 반환한다.
*
* @param string $templateCode 카카오 템플릿 코드
* @return array<string, mixed>|null 카카오 상세(templateContent/buttons/…) 또는 실패 시 null
*/
public function resolve(string $templateCode): ?array
{
$ttl = $this->cacheTtl();
// TTL=0 → 캐시 우회, 매번 실시간 조회.
if ($ttl <= 0) {
return $this->fetch($templateCode);
}
$cacheKey = self::CACHE_KEY_PREFIX.$templateCode;
// 캐시 히트면 그대로 반환. 미스면 조회해 캐시.
$cached = $this->cache->get($cacheKey);
if (is_array($cached)) {
return $cached;
}
$content = $this->fetch($templateCode);
// 조회 실패(null)는 캐시하지 않는다 — 다음 발송에서 재시도 가능하도록.
if ($content !== null) {
$this->cache->put($cacheKey, $content, $ttl);
}
return $content;
}
/**
* 특정 템플릿의 캐시를 무효화합니다 (관리자 수동 초기화).
*
* @param string $templateCode 카카오 템플릿 코드
*/
public function clear(string $templateCode): void
{
$this->cache->forget(self::CACHE_KEY_PREFIX.$templateCode);
}
/**
* 여러 템플릿의 캐시를 한 번에 무효화합니다 (관리자 전체 초기화).
*
* 캐시 키가 template_code 단위이므로, 연결된 모든 알림톡 템플릿 코드를 받아 각각 비운다.
* 다음 발송에서 최신 내용으로 재조회된다.
*
* @param array<int, string> $templateCodes 카카오 템플릿 코드 목록
* @return int 비운 캐시 키 수(중복 제거 후)
*/
public function clearMany(array $templateCodes): int
{
$codes = array_values(array_unique(array_filter($templateCodes, static fn ($c) => $c !== '')));
foreach ($codes as $code) {
$this->clear((string) $code);
}
return count($codes);
}
/**
* 카카오 상세조회를 수행합니다. 실패는 null(예외 삼킴 + 로그).
*
* rate limit(HTTP 429 또는 결과코드 5002)은 조회 폭주 상황이므로 짧게 대기 후 1회
* 재시도한다(무한 방지). 재시도해도 rate limit 이면 null 을 반환해 호출측이 발송을 skip
* 하게 한다. 그 외 실패(고아 template_code·자격증명 오류 등)는 재시도 없이 null.
*
* @param string $templateCode 카카오 템플릿 코드
* @return array<string, mixed>|null 상세 또는 실패 시 null
*/
private function fetch(string $templateCode): ?array
{
for ($attempt = 0; $attempt <= self::RATE_LIMIT_RETRIES; $attempt++) {
try {
return $this->templates->detail($templateCode);
} catch (BizppurioApiException $e) {
// rate limit 이고 재시도 여유가 남았으면 짧게 대기 후 재시도.
if ($this->isRateLimited($e) && $attempt < self::RATE_LIMIT_RETRIES) {
usleep(self::RATE_LIMIT_RETRY_WAIT_US);
continue;
}
Log::warning('비즈뿌리오 알림톡 템플릿 내용 조회 실패 — 발송 skip 후보', [
'template_code' => $templateCode,
'result_code' => $e->getResultCode(),
'http_status' => $e->getHttpStatus(),
'message' => $e->getMessage(),
]);
return null;
}
}
return null;
}
/**
* 예외가 rate limit(HTTP 429 또는 결과코드 5002)인지 판정합니다.
*
* @param BizppurioApiException $e 조회 실패 예외
* @return bool rate limit 이면 true
*/
private function isRateLimited(BizppurioApiException $e): bool
{
return $e->getHttpStatus() === 429 || $e->getResultCode() === '5002';
}
/**
* 캐시 TTL(초)을 환경설정에서 조회합니다 (기본 60분, 0=캐시 끔).
*
* 관리자 화면·저장은 "분" 단위(template_cache_minutes)로 관리하고, 캐시 드라이버에는
* 초 단위가 필요하므로 60 을 곱해 반환한다. 0 이면 캐시를 끄므로 0 그대로 반환한다.
*
* @return int TTL(초)
*/
private function cacheTtl(): int
{
$minutes = (int) $this->pluginSettings->get(
self::PLUGIN_IDENTIFIER,
'template_cache_minutes',
self::DEFAULT_CACHE_MINUTES,
);
return max(0, $minutes) * 60;
}
}
@@ -34,10 +34,12 @@ class NotificationBindingService
/**
* @param BizppurioNotificationBindingRepositoryInterface $bindings 연동 조회/저장
* @param AlimtalkTemplateService $templates 카카오 승인 템플릿 조회
* @param KakaoTemplateContentResolver $kakaoContent 발송용 템플릿 내용 캐시(수동 초기화 위임)
*/
public function __construct(
private readonly BizppurioNotificationBindingRepositoryInterface $bindings,
private readonly AlimtalkTemplateService $templates,
private readonly KakaoTemplateContentResolver $kakaoContent,
) {}
/**
@@ -47,21 +49,57 @@ class NotificationBindingService
* 데 쓴다. 코어 목록은 코어가 렌더하므로(⚑⚑ 결정 A) 알림 정의와 조인하지 않고 binding
* 만 내려준다. 편집 모달은 def.type 으로 이 맵을 조회한다.
*
* $withAvailability=true 이면(알림톡 탭 진입 표시용) 카카오 승인 목록을 1회 조회해 각 연동에
* is_unavailable(연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가) 플래그를 부여한다.
* 저장 응답(store)처럼 프리필만 필요한 경로는 기본값(false)으로 호출해 카카오 조회를 생략한다.
*
* @param bool $withAvailability 카카오 승인 목록과 대조해 소실 여부를 부여할지 (표시용)
* @return array<string, array<string, mixed>> notification_type 키의 연동 맵
*/
public function all(): array
public function all(bool $withAvailability = false): array
{
// 소실 판정 대상 = 발송 가능한(승인) 카카오 템플릿 코드 집합. 조회 실패(카카오 장애·자격증명
// 미설정)면 null 을 반환해 판정 자체를 건너뛴다 — 살아 있는 연동이 일시 장애로 "사용 불가"로
// 오탐되어 화면이 전부 경고로 물드는 것을 막는다(발송 시점 판정과 동일한 안전측 기준).
$sendableCodes = $withAvailability ? $this->sendableTemplateCodesOrNull() : null;
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,
])
->map(function (BizppurioNotificationBinding $binding) use ($sendableCodes) {
$info = [
'notification_type' => $binding->notification_type,
'template_code' => $binding->template_code,
'template_name' => $binding->template_name,
'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled,
];
// 승인 목록 조회에 성공했을 때만 소실 여부를 부여한다(null=판정 생략).
if ($sendableCodes !== null) {
$info['is_unavailable'] = ! in_array($binding->template_code, $sendableCodes, true);
}
return $info;
})
->all();
}
/**
* 발송 가능한(승인) 카카오 템플릿 코드 집합을 반환하되, 조회 실패 시 null 을 반환합니다.
*
* approvedTemplates() 는 자격증명 미설정·카카오 장애 시 BizppurioApiException 을 던진다. 소실
* 판정은 "표시 부가정보"이므로 실패를 삼키고 null 을 반환해, 호출부가 판정을 건너뛰게 한다.
*
* @return array<int, string>|null 승인 template_code 목록(성공) 또는 null(조회 실패)
*/
private function sendableTemplateCodesOrNull(): ?array
{
try {
return array_column($this->approvedTemplates(), 'template_code');
} catch (BizppurioApiException) {
return null;
}
}
/**
* 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운).
*
@@ -116,6 +154,24 @@ class NotificationBindingService
$this->bindings->delete($notificationType, self::CHANNEL);
}
/**
* 연결된 모든 알림톡 템플릿의 발송용 내용 캐시를 초기화합니다 (관리자 수동 갱신).
*
* 카카오에서 템플릿 내용을 방금 바꿔 캐시 만료(기본 1시간)를 기다리지 않고 즉시 반영하고
* 싶을 때 사용한다. 연결(binding)된 template_code 를 모아 각 캐시를 비우면 다음 발송에서
* 최신 내용으로 재조회된다.
*
* @return int 초기화한 캐시 키 수(연결된 고유 template_code 수)
*/
public function clearTemplateContentCache(): int
{
$codes = $this->bindings->allByChannel(self::CHANNEL)
->pluck('template_code')
->all();
return $this->kakaoContent->clearMany($codes);
}
/**
* 코어 편집 모달 전용 칸이 넘긴 값으로 연동을 반영합니다 (저장 훅 편승).
*
@@ -25,8 +25,13 @@ class ResultCodeResolver
/**
* 재시도(일시 오류) 코드 — 큐가 재시도해야 하는 코드.
*
* 공통 일시오류(5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류
* (7306 카카오 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 포함한다.
* 7305(성공 불확실)는 이미 발송됐을 수 있어 중복발송 위험이 있으므로 제외한다.
* 출처: 비즈뿌리오 공식 응답코드(bizppurio.github.io/response-codes).
*/
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013'];
private const RETRYABLE_CODES = ['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'];
/**
* 잔액 부족 코드 — 문자 9070 / 알림톡 7436 (D3 자체 알림 대상).
@@ -83,6 +83,13 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
| 페이지 알림톡 템플릿 탭이 소비한다.
*/
Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () {
// 발송 템플릿 내용 캐시 초기화(수동 갱신) — 카카오에서 템플릿을 방금 바꿔 즉시 반영이
// 필요할 때 관리자가 캐시를 비운다. 쓰기 동작이므로 messaging.manage 권한. 구체 경로를
// 먼저 두어 GET /{templateCode} 와 충돌하지 않게 한다.
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () {
Route::post('/cache/clear', [AlimtalkTemplateController::class, 'clearCache'])->name('cache.clear');
});
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () {
Route::get('/', [AlimtalkTemplateController::class, 'index'])->name('index');
Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories');
@@ -9,6 +9,7 @@ use Mockery;
use Mockery\MockInterface;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
@@ -190,6 +191,48 @@ class AlimtalkTemplateControllerTest extends PluginTestCase
$response->assertJsonPath('errors.kakao_message', '발신프로필을 찾을 수 없습니다.');
}
/**
* @scenario auth=manage,action=clear_cache
*
* @effects clear_cache_delegates_to_binding_service_and_returns_count
*/
public function test_manage권한으로_발송내용_캐시를_초기화한다(): void
{
$bindings = Mockery::mock(NotificationBindingService::class);
$bindings->shouldReceive('clearTemplateContentCache')->once()->andReturn(3);
$this->app->instance(NotificationBindingService::class, $bindings);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE.'/cache/clear');
$response->assertStatus(200);
$response->assertJsonPath('data.cleared', 3);
}
/**
* @scenario auth=view_only,action=clear_cache
*
* @effects clear_cache_requires_manage_permission_returns_403
*/
public function test_캐시초기화는_manage권한이_없으면_403(): void
{
// 조회(view) 권한만으로는 캐시 초기화(쓰기)를 할 수 없다.
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->postJson(self::BASE.'/cache/clear');
$response->assertStatus(403);
}
/**
* @scenario auth=guest,action=clear_cache
*
* @effects clear_cache_requires_authentication_returns_401
*/
public function test_캐시초기화는_비인증이면_401(): void
{
$this->postJson(self::BASE.'/cache/clear')->assertStatus(401);
}
protected function tearDown(): void
{
Mockery::close();
@@ -86,6 +86,22 @@ class SendMessageJobTest extends PluginTestCase
}
}
public function test_알림톡_일시오류_결과코드는_예외를_던져_재시도한다(): void
{
// 알림톡 일시오류(7306 카카오 시스템오류·7307 처리지연·7421 타임아웃·7437 요청실패)는
// 다시 보내면 성공할 수 있으므로 예외를 던져 큐가 재시도해야 한다.
foreach (['7306', '7307', '7421', '7437'] as $code) {
$job = new SendMessageJob($this->payload, 'ref1');
try {
$job->handle($this->makeClient(['code' => (int) $code, 'description' => 'temp']), $this->dispatches());
$this->fail("코드 {$code} 는 재시도(예외)여야 한다");
} catch (BizppurioApiException $e) {
$this->assertSame($code, $e->getResultCode(), "코드 {$code}");
}
}
}
public function test_영구실패_결과코드는_예외없이_종료하고_이력을_failed로_갱신한다(): void
{
$dispatch = $this->seedPending('ref1');
@@ -16,7 +16,9 @@ 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\AlimtalkPayloadMapper;
use Plugins\Sirsoft\MessageBizppurio\Services\DispatchLinkContext;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Services\MessagePayloadBuilder;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
@@ -73,25 +75,56 @@ class AlimtalkChannelDriverTest extends PluginTestCase
return $binding;
}
/**
* 카카오 상세조회 내용을 반환하는 KakaoTemplateContentResolver mock 을 만듭니다.
*
* @param array<string, mixed>|null $content resolve 반환값 (null=조회 실패 → skip)
*/
private function fakeKakaoContent(?array $content): KakaoTemplateContentResolver
{
$resolver = Mockery::mock(KakaoTemplateContentResolver::class);
$resolver->shouldReceive('resolve')->andReturn($content);
return $resolver;
}
/**
* 기본 카카오 상세(본문만 있는 단순 승인 템플릿).
*
* @return array<string, mixed>
*/
private function kakaoContent(string $templateContent = '#{name}님 주문이 완료되었습니다.'): array
{
return ['templateCode' => 'TW_1234', 'templateContent' => $templateContent];
}
/**
* 템플릿·binding·빌더를 조합한 driver 를 만듭니다.
*
* @param array<string, mixed>|null $kakao 카카오 상세조회 내용 (null=조회 실패)
*/
private function makeDriver(
?NotificationTemplate $template,
?BizppurioNotificationBinding $binding,
?MessagePayloadBuilder $builder = null,
array|false|null $kakao = false,
): AlimtalkChannelDriver {
$templateService = Mockery::mock(NotificationTemplateService::class);
$templateService->shouldReceive('resolve')
->with(Mockery::any(), 'alimtalk')
->andReturn($template);
// $kakao 기본값 false = "기본 카카오 내용 제공"(대부분 테스트). null = 조회 실패.
$content = $kakao === false ? $this->kakaoContent() : $kakao;
return new AlimtalkChannelDriver(
$templateService,
$this->fakeBindings($binding),
$builder ?? $this->spyBuilder(),
new BizppurioDispatchRepository,
new DispatchLinkContext,
$this->fakeKakaoContent($content),
new AlimtalkPayloadMapper,
);
}
@@ -143,17 +176,30 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$this->assertDatabaseCount('bizppurio_dispatches', 0);
}
public function test_alimtalk_템플릿이_없으면_발송하지_않는다(): void
public function test_카카오_템플릿_내용_조회_실패시_발송하지_않는다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
// binding 은 있으나 코어 alimtalk 본문 템플릿이 없음 → skip
$this->makeDriver(null, $this->binding())->send($member, $this->notification());
// binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음 → skip
$this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null)
->send($member, $this->notification());
Bus::assertNotDispatched(SendMessageJob::class);
}
public function test_코어_alimtalk_템플릿이_없어도_카카오_내용으로_발송한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
// 알림톡 본문은 카카오에서 오므로, 코어 alimtalk 템플릿(SMS 대체용)이 없어도 발송돼야 한다.
// 대체발송 OFF 이면 코어 템플릿을 아예 조회하지 않는다.
$this->makeDriver(null, $this->binding(fallback: false))->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class);
}
public function test_회원은_mobile로_연결된_템플릿코드로_발송한다(): void
{
Bus::fake();
@@ -183,42 +229,64 @@ class AlimtalkChannelDriverTest extends PluginTestCase
$this->assertSame('01099990000', $builder->calls[0]['to']);
}
public function test_본문_변수를_알림톡_형식으로_변환한다(): void
public function test_카카오_본문의_변수를_알림_data로_치환해_발송한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$builder = $this->spyBuilder();
// 알림톡 본문은 카카오 승인 템플릿(#{var})에서 오고, 발송 시 알림 data 로 치환된다.
$this->makeDriver(
$this->fakeTemplate(['subject' => '', 'body' => '{name} 님 {order_number} 주문 완료']),
$this->fakeTemplate(),
$this->binding(),
$builder,
)->send($member, $this->notification());
kakao: ['templateCode' => 'TW_1234', 'templateContent' => '#{name}님 #{order_number} 주문 완료'],
)->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1']));
$this->assertSame(
'#{name} 님 #{order_number} 주문 완료',
'김철수님 A1 주문 완료',
$builder->calls[0]['message'],
'코어 {var} 형식을 알림톡 #{var} 형식으로 치환해야 한다.',
'카카오 본문의 #{var} 를 알림 data 로 치환해야 한다.',
);
}
public function test_이미_알림톡_형식인_변수는_중복_변환하지_않는다(): void
public function test_카카오_버튼을_발송_extra로_전달한다(): void
{
Bus::fake();
$member = User::factory()->create(['mobile' => '01011112222']);
$builder = $this->spyBuilder();
// 버튼 URL 변수까지 치환돼 payload button 에 실려야 한다(회귀: 이전엔 버튼 자체가 누락).
$builder = new class extends MessagePayloadBuilder
{
/** @var array<int, array<string, mixed>> */
public array $extras = [];
public function __construct() {}
public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array
{
$this->extras[] = $extra;
return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => array_merge(['message' => $message], $extra)]];
}
};
$this->makeDriver(
$this->fakeTemplate(['subject' => '', 'body' => '#{name} 님 {order_number}']),
$this->fakeTemplate(),
$this->binding(),
$builder,
)->send($member, $this->notification());
kakao: [
'templateCode' => 'TW_1234',
'templateContent' => '#{name}님 주문 완료',
'buttons' => [
['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/orders/#{order_number}'],
],
],
)->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1']));
$this->assertSame(
'#{name} 님 #{order_number}',
$builder->calls[0]['message'],
'이미 #{var} 인 변수는 ##{var} 로 이중 변환되면 안 된다.',
);
$button = $builder->extras[0]['button'][0];
$this->assertSame('WL', $button['type']);
$this->assertSame('https://m.shop/orders/A1', $button['url_mobile'], '버튼 URL 변수도 치환돼 발송돼야 한다.');
}
public function test_대체발송_o_n이면_payload에_sms_resend가_병합된다(): void
@@ -305,4 +373,17 @@ class AlimtalkChannelDriverTest extends PluginTestCase
Bus::assertNotDispatched(SendMessageJob::class);
}
public function test_정상_조건에서_발송하고_이력을_생성한다(): void
{
// 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다.
// 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다.
Bus::fake();
$member = User::factory()->create(['mobile' => '010-1234-5678']);
$this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification());
Bus::assertDispatched(SendMessageJob::class);
$this->assertDatabaseCount('bizppurio_dispatches', 1);
}
}
@@ -0,0 +1,169 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkPayloadMapper;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* AlimtalkPayloadMapper — 카카오 상세조회 응답 → 발송 API content.at 변환 + 변수 치환 검증 (B안 5-2).
*
* 카카오 관리 API 필드(templateContent/buttons.linkMo/…)를 발송 API 필드
* (message/button.url_mobile/…)로 변환하고, 각 필드의 #{var} 를 알림 data 로 치환한다.
* 빈/부재 필드는 payload 에 넣지 않는다(방어적).
*/
class AlimtalkPayloadMapperTest extends PluginTestCase
{
private function mapper(): AlimtalkPayloadMapper
{
return new AlimtalkPayloadMapper;
}
public function test_본문의_변수를_치환해_message로_반환한다(): void
{
$result = $this->mapper()->map(
['templateContent' => '#{name}님 주문 #{order_no} 완료'],
['name' => '홍길동', 'order_no' => 'A123'],
);
$this->assertSame('홍길동님 주문 A123 완료', $result['message']);
}
public function test_웹링크_버튼을_발송형식으로_변환하고_url변수를_치환한다(): void
{
$result = $this->mapper()->map(
[
'templateContent' => '본문',
'buttons' => [
[
'name' => '주문조회',
'linkType' => 'WL',
'linkMo' => 'https://m.shop/orders/#{order_no}',
'linkPc' => 'https://shop/orders/#{order_no}',
],
],
],
['order_no' => 'A123'],
);
$button = $result['extra']['button'][0];
$this->assertSame('주문조회', $button['name']);
$this->assertSame('WL', $button['type']);
$this->assertSame('https://m.shop/orders/A123', $button['url_mobile']);
$this->assertSame('https://shop/orders/A123', $button['url_pc']);
// 카카오 필드명(linkType/linkMo)은 발송 payload 에 남지 않아야 한다.
$this->assertArrayNotHasKey('linkType', $button);
$this->assertArrayNotHasKey('linkMo', $button);
}
public function test_앱링크_전화_플러그인_버튼_필드를_매핑한다(): void
{
$result = $this->mapper()->map(
[
'templateContent' => '본문',
'buttons' => [
['name' => '앱', 'linkType' => 'AL', 'linkAnd' => 'myapp://a', 'linkIos' => 'myapp://i'],
['name' => '전화', 'linkType' => 'TN', 'telNumber' => '1588-0000'],
['name' => '플러그인', 'linkType' => 'P1', 'pluginId' => 'PLUG_1'],
],
],
[],
);
$buttons = $result['extra']['button'];
$this->assertSame('myapp://a', $buttons[0]['scheme_android']);
$this->assertSame('myapp://i', $buttons[0]['scheme_ios']);
$this->assertSame('1588-0000', $buttons[1]['tel_number']);
$this->assertSame('PLUG_1', $buttons[2]['plugin_id']);
}
public function test_quick_replies를_quickreply로_변환한다(): void
{
$result = $this->mapper()->map(
[
'templateContent' => '본문',
'quickReplies' => [
['name' => '바로가기', 'linkType' => 'WL', 'linkMo' => 'https://m.shop'],
],
],
[],
);
$qr = $result['extra']['quickreply'][0];
$this->assertSame('바로가기', $qr['name']);
$this->assertSame('WL', $qr['type']);
$this->assertSame('https://m.shop', $qr['url_mobile']);
}
public function test_title_header를_치환해_매핑한다(): void
{
$result = $this->mapper()->map(
[
'templateContent' => '본문',
'templateTitle' => '#{name}님',
'templateHeader' => '주문 안내',
],
['name' => '홍길동'],
);
$this->assertSame('홍길동님', $result['extra']['title']);
$this->assertSame('주문 안내', $result['extra']['header']);
}
public function test_item과_itemhighlight를_매핑하고_치환한다(): void
{
$result = $this->mapper()->map(
[
'templateContent' => '본문',
'templateItem' => [
'list' => [
['title' => '상품', 'description' => '#{product}'],
],
'summary' => ['title' => '합계', 'description' => '#{total}원'],
],
'templateItemHighlight' => ['title' => '#{name}님', 'description' => '주문완료'],
],
['product' => '티셔츠', 'total' => '20,000', 'name' => '홍길동'],
);
$this->assertSame('티셔츠', $result['extra']['item']['list'][0]['description']);
$this->assertSame('20,000원', $result['extra']['item']['summary']['description']);
$this->assertSame('홍길동님', $result['extra']['itemhighlight']['title']);
}
public function test_대표링크를_link로_변환하고_치환한다(): void
{
$result = $this->mapper()->map(
[
'templateContent' => '본문',
'templateRepresentLink' => [
'linkMo' => 'https://m.shop/#{id}',
'linkPc' => 'https://shop/#{id}',
],
],
['id' => 'X1'],
);
$this->assertSame('https://m.shop/X1', $result['extra']['link']['url_mobile']);
$this->assertSame('https://shop/X1', $result['extra']['link']['url_pc']);
}
public function test_부재_필드는_extra에_넣지_않는다(): void
{
// 본문만 있는 단순 템플릿 → extra 는 비어야 한다.
$result = $this->mapper()->map(['templateContent' => '본문'], []);
$this->assertSame('본문', $result['message']);
$this->assertSame([], $result['extra']);
}
public function test_빈_버튼배열은_extra에_button을_만들지_않는다(): void
{
$result = $this->mapper()->map(
['templateContent' => '본문', 'buttons' => []],
[],
);
$this->assertArrayNotHasKey('button', $result['extra']);
}
}
@@ -100,6 +100,33 @@ class AlimtalkTemplateServiceTest extends PluginTestCase
$this->assertSame('green', $detail['status_badge']['variant']);
}
public function test_발신프로필은_data_success_배열을_반환한다(): void
{
// 규격(5.발신프로필관리): /v3/kakao/profile/use 응답 data 는 {success:[...], fail:[...]}
// 2단 봉투다. 실제 발신프로필 목록은 data.success 안에 있으므로 그 배열을 반환해야 한다.
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => [
'success' => [
['senderKey' => 'SK_40', 'name' => '테스트채널', 'status' => 'A'],
],
'fail' => [],
],
], 200),
]);
$profiles = $this->service()->senderProfiles();
// success 배열이 그대로 반환되어야 한다(껍데기 {success,fail} 가 아님).
$this->assertCount(1, $profiles);
$this->assertSame('SK_40', $profiles[0]['senderKey']);
$this->assertSame('테스트채널', $profiles[0]['name']);
// 회귀 방지: data 통째 반환 시 노출되던 success/fail 키가 없어야 한다.
$this->assertArrayNotHasKey('success', $profiles);
$this->assertArrayNotHasKey('fail', $profiles);
}
public function test_발신프로필_키_미설정시_예외(): void
{
$this->expectException(BizppurioApiException::class);
@@ -0,0 +1,174 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Contracts\Extension\CacheInterface;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient;
use Plugins\Sirsoft\MessageBizppurio\Services\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* KakaoTemplateContentResolver — 카카오 템플릿 내용 조회 + 캐시 검증 (B안 5-1).
*
* 발송 시 카카오 상세조회로 본문·버튼·요소 원천을 가져오되, template_code 단위로
* 캐시해 rate limit 을 억제한다. TTL=0 이면 캐시를 우회(매번 조회)하고, 조회 실패는
* null 을 반환한다(호출측이 발송 skip).
*/
class KakaoTemplateContentResolverTest extends PluginTestCase
{
private const IDENTIFIER = 'sirsoft-message_bizppurio';
/**
* 테스트 간 캐시 오염 방지 — 실제 확장 캐시 드라이버를 공유하므로 각 테스트 전 비운다.
* (같은 template_code 키를 여러 테스트가 재사용해 캐시 히트가 교차 오염되는 것을 막는다.)
*/
protected function setUp(): void
{
parent::setUp();
app(CacheInterface::class)->flush();
}
/**
* @param array<string, mixed> $settings
*/
private function resolver(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): KakaoTemplateContentResolver
{
$pluginSettings = Mockery::mock(PluginSettingsService::class);
$pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings);
// template_cache_minutes 조회 (기본 60분 → 내부에서 ×60 초)
$pluginSettings->shouldReceive('get')
->with(self::IDENTIFIER, 'template_cache_minutes', Mockery::any())
->andReturnUsing(fn ($id, $key, $default) => $settings['template_cache_minutes'] ?? $default);
$kakao = new BizppurioKakaoApiClient($pluginSettings);
$templates = new AlimtalkTemplateService($kakao, $pluginSettings);
// 실제 확장 캐시 드라이버 주입 (contextual binding 과 동일 계약)
$cache = app(CacheInterface::class);
return new KakaoTemplateContentResolver($templates, $cache, $pluginSettings);
}
/**
* 상세조회 응답의 원본 카카오 필드(templateContent/buttons)를 그대로 반환한다.
*/
public function test_템플릿_내용을_조회해_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => [
'templateCode' => 'TW_1',
'templateContent' => '#{name}님 주문 완료',
'buttons' => [
['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/#{order}'],
],
'inspectionStatus' => 'APR',
'status' => 'A',
],
], 200),
]);
$content = $this->resolver()->resolve('TW_1');
$this->assertNotNull($content);
$this->assertSame('#{name}님 주문 완료', $content['templateContent']);
$this->assertSame('WL', $content['buttons'][0]['linkType']);
}
/**
* 같은 template_code 를 두 번 조회하면 두 번째는 캐시 히트 — kapi 호출은 1회뿐.
*/
public function test_두번째_조회는_캐시_히트로_kapi를_다시_부르지_않는다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
], 200),
]);
$resolver = $this->resolver();
$resolver->resolve('TW_1');
$resolver->resolve('TW_1');
Http::assertSentCount(1);
}
/**
* TTL=0 이면 캐시를 우회 — 매 조회마다 kapi 를 부른다(항상 최신).
*/
public function test_ttl이_0이면_캐시를_우회해_매번_조회한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
], 200),
]);
$resolver = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]);
$resolver->resolve('TW_1');
$resolver->resolve('TW_1');
Http::assertSentCount(2);
}
/**
* 조회 실패(고아 템플릿·장애)는 null 을 반환한다(호출측이 발송 skip).
*/
public function test_조회_실패시_null을_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '7315', 'message' => '템플릿 없음'], 200),
]);
$this->assertNull($this->resolver()->resolve('GONE'));
}
/**
* rate limit(HTTP 429)이면 짧게 재시도한 뒤 성공하면 내용을 반환한다(조회 폭주 완화).
*/
public function test_rate_limit_429는_재시도해_성공하면_반환한다(): void
{
// 첫 응답 429 → 재시도 → 두 번째 성공.
Http::fakeSequence('kapi.ppurio.com/*')
->push(['code' => '5002', 'description' => 'too many requests'], 429)
->push([
'code' => '200',
'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
], 200);
$content = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0])
->resolve('TW_1');
$this->assertNotNull($content);
$this->assertSame('본문', $content['templateContent']);
Http::assertSentCount(2);
}
/**
* 재시도해도 계속 429면 null 을 반환한다(무한 재시도 방지).
*/
public function test_rate_limit이_지속되면_null을_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '5002', 'description' => 'too many requests'], 429),
]);
$this->assertNull(
$this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0])
->resolve('TW_1'),
);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
@@ -5,6 +5,7 @@ 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\KakaoTemplateContentResolver;
use Plugins\Sirsoft\MessageBizppurio\Services\NotificationBindingService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
@@ -30,14 +31,27 @@ class NotificationBindingServiceTest extends PluginTestCase
return $service;
}
private function makeService(array $templates = []): NotificationBindingService
private function makeService(array $templates = [], ?KakaoTemplateContentResolver $kakaoContent = null): NotificationBindingService
{
return new NotificationBindingService(
new BizppurioNotificationBindingRepository,
$this->fakeTemplateService($templates),
$kakaoContent ?? $this->fakeKakaoContent(),
);
}
/**
* clearMany 호출 인자를 기록하는 KakaoTemplateContentResolver mock 을 만듭니다.
*/
private function fakeKakaoContent(): KakaoTemplateContentResolver
{
$resolver = Mockery::mock(KakaoTemplateContentResolver::class);
$resolver->shouldReceive('clearMany')
->andReturnUsing(fn (array $codes) => count(array_unique(array_filter($codes))));
return $resolver;
}
public function test_all은_알림톡_연동을_type_키_맵으로_반환한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
@@ -60,6 +74,77 @@ class NotificationBindingServiceTest extends PluginTestCase
$this->assertSame([], $this->makeService()->all());
}
public function test_all_withAvailability는_승인목록에_있는_연동을_사용가능으로_표시한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_LIVE',
'template_name' => '살아있음',
'is_active' => true,
]);
// 승인 목록에 TW_LIVE 가 있음 → 사용 가능(is_unavailable=false)
$map = $this->makeService([
['templateCode' => 'TW_LIVE', 'templateName' => '살아있음', 'serviceStatus' => 'ACT'],
])->all(withAvailability: true);
$this->assertFalse($map['order_confirmed']['is_unavailable']);
}
public function test_all_withAvailability는_승인목록에_없는_연동을_사용불가로_표시한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_GONE',
'template_name' => '삭제됨',
'is_active' => true,
]);
// 승인 목록에 TW_GONE 이 없음(삭제·차단·미승인) → 사용 불가(is_unavailable=true)
$map = $this->makeService([
['templateCode' => 'TW_OTHER', 'templateName' => '다른템플릿', 'serviceStatus' => 'ACT'],
])->all(withAvailability: true);
$this->assertTrue($map['order_confirmed']['is_unavailable']);
}
public function test_all_withAvailability는_카카오_조회_실패시_소실판정을_생략한다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_1234',
'template_name' => '주문완료',
'is_active' => true,
]);
// 카카오 승인 목록 조회가 실패(자격증명 미설정·장애)하면 판정을 건너뛴다 —
// 살아있는 연동이 일시 장애로 "사용 불가"로 오탐되지 않게(is_unavailable 필드 미부여).
$throwingTemplates = Mockery::mock(AlimtalkTemplateService::class);
$throwingTemplates->shouldReceive('list')
->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정'));
$service = new NotificationBindingService(
new BizppurioNotificationBindingRepository,
$throwingTemplates,
$this->fakeKakaoContent(),
);
$map = $service->all(withAvailability: true);
$this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed'], '조회 실패 시 소실 판정을 생략해야 한다.');
}
public function test_all은_기본값에서_소실판정을_하지_않는다(): void
{
(new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
'template_code' => 'TW_1234',
'template_name' => '주문완료',
'is_active' => true,
]);
// store(저장) 경로처럼 프리필만 필요하면 카카오 대조를 생략한다(is_unavailable 필드 없음).
$map = $this->makeService()->all();
$this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed']);
}
public function test_승인_상태_템플릿만_연결_후보로_노출한다(): void
{
$service = $this->makeService([
@@ -141,4 +226,34 @@ class NotificationBindingServiceTest extends PluginTestCase
'channel' => 'alimtalk',
]);
}
public function test_캐시초기화는_연결된_모든_template_code를_resolver에_넘긴다(): void
{
// 서로 다른 알림에 두 템플릿을 연결.
$repo = new BizppurioNotificationBindingRepository;
$repo->upsert('order_confirmed', 'alimtalk', ['template_code' => 'TW_1', 'template_name' => 'A', 'is_active' => true]);
$repo->upsert('welcome', 'alimtalk', ['template_code' => 'TW_2', 'template_name' => 'B', 'is_active' => true]);
// resolver 가 실제로 받은 코드 목록을 포착.
$received = null;
$resolver = Mockery::mock(KakaoTemplateContentResolver::class);
$resolver->shouldReceive('clearMany')
->once()
->andReturnUsing(function (array $codes) use (&$received) {
$received = $codes;
return count($codes);
});
$cleared = $this->makeService([], $resolver)->clearTemplateContentCache();
$this->assertSame(2, $cleared);
$this->assertContains('TW_1', $received);
$this->assertContains('TW_2', $received);
}
public function test_캐시초기화는_연동이_없으면_0을_반환한다(): void
{
$this->assertSame(0, $this->makeService()->clearTemplateContentCache());
}
}
@@ -46,14 +46,26 @@ class ResultCodeResolverTest extends PluginTestCase
/**
* 일시오류 코드는 Retry.
*
* 공통(5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류(7306 카카오 시스템오류·
* 7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 재시도 대상이다. 7305(성공 불확실)는
* 이미 발송됐을 수 있어 중복발송 위험이 있으므로 재시도 대상에서 제외한다.
*/
public function test_retryable_codes_categorized_as_retry(): void
{
foreach (['5003', '5004', '5005', '9000', '3011', '3013'] as $code) {
foreach (['5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) {
$this->assertSame(ResultCategory::Retry, $this->resolver->categorize($code), "코드 {$code}");
}
}
/**
* 7305(성공 불확실)는 중복발송 위험으로 재시도 대상이 아니다(영구실패로 분류).
*/
public function test_success_uncertain_7305_is_not_retryable(): void
{
$this->assertNotSame(ResultCategory::Retry, $this->resolver->categorize('7305'));
}
/**
* 위 분류에 없는 코드는 영구 실패.
*/
@@ -2,7 +2,10 @@
# Phase 6(재설계)는 알림톡 실발송 위임(백엔드) + 시딩 필터 훅 template 증강 + 코어 편집 모달
# 전용 칸(즉시 저장) + 상태 배너 overlay 이다. axes cross product 의 런타임 효과는 계층별로
# 전수 가드된다:
# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/변수치환/대체발송/전화번호/이력.
# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/카카오 내용 발송/버튼 extra/대체발송/전화번호/이력/플러그인 활성.
# - 카카오 내용 조회·캐시(kakao_content/content_cache 축): KakaoTemplateContentResolverTest — 조회/캐시 히트/TTL=0 우회/429 재시도/실패 null.
# - 카카오→발송 변환(kakao_elements 축): AlimtalkPayloadMapperTest — 본문/버튼/바로연결/title·header·item·highlight·link 변환 + #{var} 치환 + 부재 필드 생략.
# - 캐시 수동 초기화 엔드포인트·권한: AlimtalkTemplateControllerTest + NotificationBindingServiceTest.
# - template 증강: SeedChannelTemplatesListenerTest — 회원판정/기본문구/채널추가/멱등.
# - 연동 즉시저장·조회·권한: NotificationBindingServiceTest + NotificationBindingEndpointTest.
# - 채널 메타(is_test_mode): RegisterNotificationChannelsListenerTest.
@@ -24,9 +27,16 @@ description: |
핵심 동작:
- SeedChannelTemplatesListener: 코어/모듈 시딩 필터 훅(seed.*.notifications.translations)에
편승해 회원 알림에 sms·alimtalk template 증강(cleanupStaleTemplates 에도 유지).
- AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip.
- 변수 치환: 코어 {var} → 알림톡 #{var}(D8, 매핑 UI 없음).
- AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. 플러그인 비활성 시 차단.
- 카카오 내용 반영(B안): 발송 본문·버튼·요소를 카카오 승인 템플릿(templateContent/buttons/
quickReplies/title/header/item/itemHighlight/representLink)에서 상세조회로 가져와 채운다.
비즈뿌리오 발송 API 는 완성본을 요구하므로 templatecode 만으로는 안 된다.
· KakaoTemplateContentResolver: 상세조회 + template_code 단위 캐시(기본 1h, TTL=0 끔,
429/5002 rate limit 시 1회 재시도, 실패 null → skip).
· AlimtalkPayloadMapper: 카카오 필드 → 발송 형식 변환(linkMo→url_mobile 등) + #{var} 치환.
· 캐시 수동 초기화 엔드포인트/버튼(manage 권한): 카카오 변경 즉시 반영.
- 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합.
대체 SMS 본문은 카카오와 무관하게 코어 alimtalk 템플릿에서 렌더(빈 본문이면 미병합).
- 행 연결 UI(extension_point notification_definition_row_footer): 알림톡 탭의 각 알림 행 하단에
연결 상태 줄(템플릿명·SMS대체) + [연결/변경] 버튼. channel==='alimtalk' 일 때만.
- 연결 모달(overlay modals, 우리 소유): 코어 편집 모달과 분리. 안내 카드 + 연결 템플릿
@@ -43,25 +53,46 @@ axes:
member_facing: [member_target, admin_only]
banner_state: [not_ready, test_mode, normal]
e2e_binding_action: [connect, change, unbind]
# B안 — 카카오 내용 반영·캐시
kakao_content: [fetched, orphan_or_error, rate_limited]
kakao_elements: [body_only, with_button, with_quickreply, with_title, with_item, with_link]
content_cache: [hit, miss, ttl_zero, manual_cleared]
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 축과 무관" }
- { kakao_content: orphan_or_error, kakao_elements: with_button, reason: "조회 실패 시 내용 자체가 없어 요소 축 무의미 — skip 으로 수렴" }
- { kakao_content: rate_limited, kakao_elements: with_button, reason: "rate limit 은 조회 단계 — 재시도 소진 시 null → skip, 요소 축 무의미" }
- { content_cache: manual_cleared, kakao_elements: with_item, reason: "캐시 초기화는 조회 시점 축 — 요소 종류와 직교, 대표 조합만 커버" }
effects:
# 발송 위임 (AlimtalkChannelDriver)
- alimtalk_dispatched_with_bound_template_code
- dispatch_skipped_when_no_binding
- dispatch_skipped_when_no_alimtalk_template
- dispatch_skipped_when_plugin_inactive
- 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
- fallback_sms_body_from_core_template_not_kakao
- fallback_skipped_when_core_template_missing
- pending_dispatch_created_with_alimtalk_channel
# 카카오 내용 반영 (KakaoTemplateContentResolver + AlimtalkPayloadMapper) — B안
- alimtalk_body_from_kakao_template_content
- dispatch_skipped_when_kakao_content_unavailable
- dispatch_can_send_without_core_alimtalk_template
- kakao_variables_substituted_from_notification_data
- kakao_buttons_mapped_to_send_form_with_url_substitution
- kakao_quickreplies_mapped_to_send_form
- kakao_title_header_item_highlight_link_mapped
- absent_kakao_fields_omitted_from_payload
- content_cache_hit_skips_kapi_call
- content_cache_ttl_zero_bypasses_cache
- content_cache_rate_limit_retried_then_null
- manual_cache_clear_forgets_bound_template_codes
- manual_cache_clear_requires_manage_permission
# template 증강 (SeedChannelTemplatesListener)
- member_facing_notification_gets_sms_and_alimtalk_channels
- admin_only_notification_not_augmented
@@ -101,10 +132,14 @@ effects:
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/Services/KakaoTemplateContentResolverTest.php
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.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
- plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx
audit:
status: codified