feat(message_bizppurio): 알림톡 템플릿 관리 화면 (설정 페이지 탭)

설정 페이지 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 등록·수정·삭제·검수·
상태변경한다. 진입은 코어 소유 설정 페이지 하나로 통일하고 getAdminMenus 는
폐기했다. 템플릿은 kapi 에 실시간 위임하며 DB 에 저장하지 않는다.

등록 폼은 계획서 §6-4 전체를 구현한다: 템플릿 코드(자동 생성/직접 입력),
카테고리 2단 드롭다운, 템플릿 유형(기본형/이미지형/강조표기형), 이미지형
파일 업로드, 본문 변수 클릭 삽입 + 알림별 변수 참고표, 선택 항목 6개
체크펼침(부가정보·미리보기·버튼·바로연결·대표링크·보안), 등록/등록 후
검수요청 2버튼.
This commit is contained in:
chym1217 committed 2026-07-28 12:36:22 +09:00
1 parent 77df8c9a7c
commit 73b05aa42f
37 files changed
+4902 -111

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 · **엔드포인트 수**: 374
- **확장 수**: 10 · **엔드포인트 수**: 388
| 확장 | 유형 | 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) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 3 / 16 |
| `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 |
@@ -11,10 +11,15 @@
- 비즈뿌리오 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다.
- 연동 환경을 운영으로 설정하면 발송에 필요한 아이디·비밀번호·발신번호가 반드시 입력되도록 검증하고, 실제 발송·비용이 발생한다는 경고를 함께 안내합니다.
- 발송 결과(리포트)를 받을 수신 주소를 설정 화면에서 확인·복사할 수 있습니다. 이 주소를 비즈뿌리오에 등록하면 발송 결과가 이력에 자동 기록됩니다.
- 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다.
- 비즈뿌리오 메시지 발송 기능에 대한 조회·관리 권한을 추가했습니다. 관련 화면은 플러그인 설정 페이지에서 진입합니다.
- 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다.
- 회원가입·주문 등의 알림을 문자(SMS/LMS)로 자동 발송하는 채널을 알림 설정에 추가했습니다. 본문 길이에 따라 SMS·LMS 가 자동으로 선택되며, 발신 정보가 설정되지 않은 경우 어떤 항목을 채워야 하는지 안내합니다.
- 비회원 주문 알림도 주문 시 입력한 연락처로 문자를 받을 수 있습니다.
- 카카오 알림톡 채널을 알림 설정에 추가했습니다. (알림톡 실제 발송·템플릿 연동은 후속 단계에서 제공됩니다.)
- 비즈뿌리오가 보내는 발송 결과 통보를 받아 각 발송의 실제 도달 여부(성공/실패)를 기록합니다. 실패한 경우 사유를 함께 확인할 수 있습니다(예: 음영 지역, 잘못된 번호, 스팸 차단, 지갑 잔액 부족 등). (발송 결과를 확인하는 화면은 후속 단계에서 제공됩니다.)
- 지갑 잔액 부족으로 발송이 실패하면 관리자에게 알립니다. 대량 실패 시 알림이 반복 발송되지 않도록 일정 시간 간격으로 한 번만 안내합니다.
- 설정 페이지의 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 직접 등록·수정·삭제하고 검수를 요청할 수 있습니다. 등록한 템플릿의 상태(발송가능·검수중·반려·미검수·중지 등)를 배지로 확인하고, 상태별로 가능한 관리 동작(검수 요청·중지·승인 취소 등)을 바로 실행할 수 있습니다.
- 템플릿 등록 화면에서 템플릿명·템플릿 코드(자동 생성 또는 직접 입력)·카테고리(대분류/소분류)·템플릿 유형(기본형·이미지형·강조표기형)·본문을 입력하고, 우측에서 실시간 미리보기를 확인할 수 있습니다. 이미지형은 이미지를 첨부하면 자동으로 업로드됩니다. (아이템리스트형 등 고급 유형은 비즈뿌리오 홈페이지에서 등록한 뒤에도 목록에서 함께 관리할 수 있습니다.)
- 본문에 넣을 수 있는 변수를 클릭하면 커서 위치에 바로 삽입되며, 알림 종류별로 제공되는 변수 예시를 참고표로 함께 안내합니다.
- 선택 항목(부가정보·미리보기 메시지·버튼·바로연결·대표 링크·템플릿 보안)을 체크해 필요한 것만 펼쳐 입력할 수 있습니다.
- 템플릿은 [등록]으로 임시 저장하거나 [등록 후 검수요청]으로 등록과 동시에 카카오 검수를 요청할 수 있습니다.
@@ -0,0 +1,2 @@
var SirsoftMessageBizppurio=(function(f){"use strict";const w=window.G7Core?.createLogger?.("MessageBizppurio:InsertVariable")??{log:(...e)=>console.log("[MessageBizppurio:InsertVariable]",...e),warn:(...e)=>console.warn("[MessageBizppurio:InsertVariable]",...e),error:(...e)=>console.error("[MessageBizppurio:InsertVariable]",...e)},y="templateContent",A="templateForm.templateContent";function C(e,o){const t=window.G7Core,g=e.params??{},n=String(g.variable??"").trim(),a=String(g.target??y),s=String(g.statePath??A);if(n===""){w.warn("삽입할 변수명이 지정되지 않았습니다.");return}if(!t?.state?.getLocal||!t?.state?.setLocal){w.error("G7Core.state API 를 사용할 수 없습니다.");return}const d=o?.state??t.state.getLocal()??{},r=String(P(d,s)??""),i=`#{${n}}`,l=document.querySelector(`textarea[name="${a}"], input[name="${a}"]`),u=(l?.value??"")===r,b=u?l?.selectionStart??r.length:r.length,E=u?l?.selectionEnd??r.length:r.length,M=r.slice(0,b)+i+r.slice(E);if(t.state.setLocal({[s]:M}),l){const $=b+i.length;requestAnimationFrame(()=>{try{l.focus(),l.setSelectionRange($,$)}catch{}})}w.log(`변수 삽입: ${i} → ${s}`)}function P(e,o){return o.split(".").reduce((t,g)=>t?.[g],e)}const p=window.G7Core?.createLogger?.("MessageBizppurio:UploadImage")??{log:(...e)=>console.log("[MessageBizppurio:UploadImage]",...e),warn:(...e)=>console.warn("[MessageBizppurio:UploadImage]",...e),error:(...e)=>console.error("[MessageBizppurio:UploadImage]",...e)},G="/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image";async function z(e,o){const t=window.G7Core;if(!t?.state?.setLocal){p.error("G7Core.state.setLocal 을 사용할 수 없습니다.");return}const n=(o?.event??e?.event)?.target,a=n?.files?.[0];if(a){t.state.setLocal({imageUploading:!0,imageUploadError:null});try{const s=localStorage.getItem("auth_token")??"",d=new FormData;d.append("image",a,a.name);const r=await fetch(G,{method:"POST",headers:{Accept:"application/json",...s?{Authorization:`Bearer ${s}`}:{}},body:d}),i=await r.json().catch(()=>({}));if(!r.ok||i?.success===!1){const u=i?.errors?.kakao_message||i?.message||"이미지 업로드에 실패했습니다.";t.state.setLocal({imageUploading:!1,imageUploadError:u}),p.warn("이미지 업로드 실패:",u);return}const l=i?.data?.image_url??"",U=i?.data?.image_name??a.name;t.state.setLocal({imageUploading:!1,imageUploadError:null,"templateForm.templateImageUrl":l,"templateForm.templateImageName":U}),p.log("이미지 업로드 완료:",l)}catch(s){t.state.setLocal({imageUploading:!1,imageUploadError:String(s)}),p.error("이미지 업로드 예외:",s)}finally{n&&(n.value="")}}}const m={insertVariable:C,uploadTemplateImage:z},c="sirsoft-message_bizppurio",h=window.G7Core?.createLogger?.(`Plugin:${c}`)??{log:(...e)=>console.log(`[Plugin:${c}]`,...e),warn:(...e)=>console.warn(`[Plugin:${c}]`,...e),error:(...e)=>console.error(`[Plugin:${c}]`,...e)};function I(e){Object.entries(m).forEach(([o,t])=>{e.registerHandler(`${c}.${o}`,t,{category:"plugin",source:c})}),h.log(`${Object.keys(m).length} handler(s) registered:`,Object.keys(m).map(o=>`${c}.${o}`))}function S(e){const o=window.G7Core?.getActionDispatcher?.();if(o){I(o);return}if(!e){h.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let t=0;const g=50,n=()=>{const a=window.G7Core?.getActionDispatcher?.();if(a){I(a);return}++t<=g?setTimeout(n,100):h.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};n()}function L(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>S(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();S(!e)}}return L(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:c,handlers:Object.keys(m),initPlugin:L}),f.initPlugin=L,Object.defineProperty(f,Symbol.toStringTag,{value:"Module"}),f})({});
//# sourceMappingURL=plugin.iife.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin.iife.js","sources":["../../resources/js/handlers/insertVariable.ts","../../resources/js/handlers/uploadTemplateImage.ts","../../resources/js/handlers/index.ts","../../resources/js/index.ts"],"sourcesContent":["/**\n * insertVariable 핸들러\n *\n * 알림톡 템플릿 등록/수정 화면에서 \"사용 가능 변수\" 버튼을 클릭하면, 대상 본문의 현재\n * 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.<statePath>`)를 SSoT\n * 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌\n * (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다.\n *\n * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.\n *\n * 레이아웃 JSON 사용 예:\n * {\n * \"handler\": \"sirsoft-message_bizppurio.insertVariable\",\n * \"params\": { \"variable\": \"name\", \"target\": \"templateContent\", \"statePath\": \"templateForm.templateContent\" }\n * }\n */\n\nimport type { ActionContext, ActionWithParams } from '../types';\n\nconst logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {\n log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),\n warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),\n error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),\n};\n\n/** 삽입 대상 본문 필드(name) 기본값 */\nconst DEFAULT_TARGET = 'templateContent';\n\n/** 상태 경로 기본값 (_local 하위) */\nconst DEFAULT_STATE_PATH = 'templateForm.templateContent';\n\n/**\n * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.\n *\n * @param action 액션 객체(params.variable, params.target, params.statePath)\n * @param context 액션 컨텍스트(state 스냅샷)\n */\nexport function insertVariableHandler(\n action: ActionWithParams,\n context: ActionContext,\n): void {\n const G7Core = (window as any).G7Core;\n const params = action.params ?? {};\n const variable = String(params.variable ?? '').trim();\n const target = String(params.target ?? DEFAULT_TARGET);\n const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);\n\n if (variable === '') {\n logger.warn('삽입할 변수명이 지정되지 않았습니다.');\n return;\n }\n\n if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) {\n logger.error('G7Core.state API 를 사용할 수 없습니다.');\n return;\n }\n\n // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal.\n const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {};\n const current = String(readPath(localState, statePath) ?? '');\n\n const token = `#{${variable}}`;\n\n // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.\n const field = document.querySelector<HTMLTextAreaElement | HTMLInputElement>(\n `textarea[name=\"${target}\"], input[name=\"${target}\"]`,\n );\n const domValue = field?.value ?? '';\n // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.\n const inSync = domValue === current;\n const start = inSync ? (field?.selectionStart ?? current.length) : current.length;\n const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;\n\n const next = current.slice(0, start) + token + current.slice(end);\n\n G7Core.state.setLocal({ [statePath]: next });\n\n // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).\n if (field) {\n const caret = start + token.length;\n requestAnimationFrame(() => {\n try {\n field.focus();\n field.setSelectionRange(caret, caret);\n } catch {\n // setSelectionRange 미지원 input — 무시\n }\n });\n }\n\n logger.log(`변수 삽입: ${token} → ${statePath}`);\n}\n\n/**\n * dot notation 경로로 객체에서 값을 읽습니다. (예: \"templateForm.templateContent\")\n *\n * @param obj 대상 객체\n * @param path dot 경로\n * @return 경로 값(없으면 undefined)\n */\nfunction readPath(obj: Record<string, any>, path: string): unknown {\n return path.split('.').reduce<any>((acc, key) => (acc == null ? undefined : acc[key]), obj);\n}\n","/**\n * uploadTemplateImage 핸들러\n *\n * 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의\n * 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을\n * `_local.templateForm.templateImageULine truncated
@@ -4,10 +4,11 @@
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
<!-- @generated:start:api-readme-index -->
- **문서 수**: 2 · **엔드포인트 수**: 2
- **문서 수**: 3 · **엔드포인트 수**: 16
| 문서 | 도메인 | 엔드포인트 |
| --- | --- | --- |
| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 14 |
| [report.md](report.md) | `report` | 1 |
| [webhook.md](webhook.md) | `webhook` | 1 |
@@ -0,0 +1,730 @@
# Alimtalk Templates API 레퍼런스
> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
---
## TL;DR (5초 요약)
```text
1. 이 문서는 실제 API 호출로 실측한 Alimtalk Templates 엔드포인트 레퍼런스입니다
2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
5. 설명(TODO) 칸은 사람이 채웁니다
```
---
## 개요
설정 페이지 알림톡 템플릿 탭에서 사용하는 관리 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 -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@index`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-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 -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.store -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.store`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@store`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateName | body | string | 예 | max 200 | <!-- TODO: 용도 --> |
| templateContent | body | string | 예 | max 1300 | <!-- TODO: 용도 --> |
| categoryCode | body | string | 예 | max 20 | <!-- TODO: 용도 --> |
| templateEmphasizeType | body | string | 예 | — | <!-- TODO: 용도 --> |
| templateCode | body | string | 아니오 | max 30 | <!-- TODO: 용도 --> |
| templateTitle | body | string | 아니오 | max 23 | <!-- TODO: 용도 --> |
| templateSubtitle | body | string | 아니오 | max 18 | <!-- TODO: 용도 --> |
| templateImageName | body | string | 아니오 | max 255 | <!-- TODO: 용도 --> |
| 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: 용도 --> |
| quickReplies | body | array | 아니오 | max 10 | <!-- TODO: 용도 --> |
| templateRepresentLink | body | array | 아니오 | — | <!-- TODO: 용도 --> |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: application/json
{
"templateName": "예시 이름",
"templateContent": "예시 내용입니다.",
"categoryCode": "예시값",
"templateEmphasizeType": "예시값",
"templateCode": "예시값",
"templateTitle": "예시 제목",
"templateSubtitle": "예시 제목",
"templateImageName": "예시 이름",
"templateImageUrl": "https://example.com",
"templatePreviewMessage": "예시값",
"templateExtra": "예시값",
"templateHeader": "예시값",
"securityFlag": true,
"requestInspection": true,
"buttons": [
"예시값"
],
"quickReplies": [
"예시값"
],
"templateRepresentLink": [
"예시값"
]
}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@categories`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories 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 -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.image -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.image`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@uploadImage`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| image | body | file | 예 | max 500 | <!-- TODO: 용도 --> |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: multipart/form-data; boundary=----G7ExampleBoundary
------G7ExampleBoundary
Content-Disposition: form-data; name="image"; filename="example.pdf"
Content-Type: application/octet-stream
(바이너리 파일 내용)
------G7ExampleBoundary--
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@profiles`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles 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 -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.destroy -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.destroy`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@destroy`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
DELETE /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.show`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@show`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.update -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.update`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@update`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
| templateName | body | string | 예 | max 200 | <!-- TODO: 용도 --> |
| templateContent | body | string | 예 | max 1300 | <!-- TODO: 용도 --> |
| categoryCode | body | string | 예 | max 20 | <!-- TODO: 용도 --> |
| templateEmphasizeType | body | string | 예 | — | <!-- TODO: 용도 --> |
| templateTitle | body | string | 아니오 | max 23 | <!-- TODO: 용도 --> |
| templateSubtitle | body | string | 아니오 | max 18 | <!-- TODO: 용도 --> |
| templateImageName | body | string | 아니오 | max 255 | <!-- TODO: 용도 --> |
| 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: 용도 --> |
| quickReplies | body | array | 아니오 | max 10 | <!-- TODO: 용도 --> |
| templateRepresentLink | body | array | 아니오 | — | <!-- TODO: 용도 --> |
**요청 예시**
```http
PUT /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode} HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
Content-Type: application/json
{
"templateName": "예시 이름",
"templateContent": "예시 내용입니다.",
"categoryCode": "예시값",
"templateEmphasizeType": "예시값",
"templateCode": "예시값",
"templateTitle": "예시 제목",
"templateSubtitle": "예시 제목",
"templateImageName": "예시 이름",
"templateImageUrl": "https://example.com",
"templatePreviewMessage": "예시값",
"templateExtra": "예시값",
"templateHeader": "예시값",
"securityFlag": true,
"requestInspection": true,
"buttons": [
"예시값"
],
"quickReplies": [
"예시값"
],
"templateRepresentLink": [
"예시값"
]
}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-approval -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-approval`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelApproval`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-approval HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-request -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cancel-request`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@cancelRequest`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/cancel-request HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.release -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.release`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@release`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/release HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.request -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.request`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@requestInspection`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/request HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.reuse -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.reuse`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@reuse`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/reuse HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.stop -->
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.stop`
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@stop`
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
**요청 파라미터**
| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
| --- | --- | --- | --- | --- | --- |
| templateCode | path | string | 예 | — | 대상 template code의 식별자 |
**요청 예시**
```http
POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}/stop HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
<!-- 실측 제외: unresolved-path-param — 응답 필드는 사람이 작성하세요. -->
**응답 예시**
<!-- 실측 제외: unresolved-path-param — 응답 예시는 사람이 작성하세요. -->
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
@@ -0,0 +1,34 @@
<?php
return [
// Action labels — unique actions are defined with full dotted keys (plan §3).
// ActivityLog::getActionLabelAttribute resolves this array first (plugin lang full action key).
'action' => [
'sirsoft-message_bizppurio' => [
'alimtalk_template' => [
'create' => 'Register alimtalk template',
'update' => 'Update alimtalk template',
'delete' => 'Delete alimtalk template',
'request' => 'Request alimtalk template inspection',
'cancel_request' => 'Cancel alimtalk template inspection request',
'stop' => 'Stop alimtalk template',
'reuse' => 'Resume alimtalk template',
'cancel_approval' => 'Cancel alimtalk template approval',
'release' => 'Release alimtalk template from dormancy',
],
],
],
// Description bodies — standard pattern activity_log.description.X (resolved by DescriptionResolver).
'description' => [
'alimtalk_template_create' => 'Registered alimtalk template (:template_name)',
'alimtalk_template_update' => 'Updated alimtalk template (:template_code)',
'alimtalk_template_delete' => 'Deleted alimtalk template (:template_code)',
'alimtalk_template_request' => 'Requested alimtalk template inspection (:template_code)',
'alimtalk_template_cancel_request' => 'Canceled alimtalk template inspection request (:template_code)',
'alimtalk_template_stop' => 'Stopped alimtalk template (:template_code)',
'alimtalk_template_reuse' => 'Resumed alimtalk template (:template_code)',
'alimtalk_template_cancel_approval' => 'Canceled alimtalk template approval (:template_code)',
'alimtalk_template_release' => 'Released alimtalk template from dormancy (:template_code)',
],
];
@@ -73,5 +73,55 @@ return [
'invalid_response' => 'Unable to parse the Bizppurio response.',
'kakao_credentials_missing' => 'Please set the ID and API key to use the Kakao management API.',
'kakao_request_failed' => 'The Kakao management API request failed.',
'sender_key_missing' => 'Please set the alimtalk sender profile key first.',
],
// Alimtalk template management (Phase 5)
'template' => [
// Action result messages
'created' => 'The alimtalk template has been registered.',
'created_requested' => 'The alimtalk template has been registered and inspection requested.',
'updated' => 'The alimtalk template has been updated.',
'deleted' => 'The alimtalk template has been deleted.',
'requested' => 'Inspection has been requested.',
'request_canceled' => 'The inspection request has been canceled.',
'stopped' => 'The template has been stopped.',
'resumed' => 'The template has been resumed.',
'approval_canceled' => 'The approval has been canceled.',
'released' => 'The template has been released from dormancy.',
// Status badges (serviceStatus)
'status' => [
'sendable' => 'Sendable',
'inspecting' => 'Inspecting',
'rejected' => 'Rejected',
'uninspected' => 'Not inspected',
'stopped' => 'Stopped',
'blocked' => 'Blocked',
'dormant' => 'Dormant',
'unknown' => 'Unknown',
],
// Template types (templateEmphasizeType)
'emphasize_type' => [
'none' => 'Basic',
'text' => 'Highlighted',
'image' => 'Image',
'item_list' => 'Item list',
],
// Validation attribute labels (FormRequest attributes)
'fields' => [
'name' => 'Template name',
'content' => 'Template content',
'category' => 'Category',
'emphasize_type' => 'Template type',
'code' => 'Template code',
'title' => 'Highlight title',
'subtitle' => 'Highlight subtitle',
'image' => 'Image',
'image_name' => 'Image file name',
'image_url' => 'Image URL',
],
],
];
@@ -0,0 +1,34 @@
<?php
return [
// 액션 라벨 — 고유 action 은 전체 dotted 키로 정의(코어 범용 세그먼트 충돌 회피, 계획서 §3).
// ActivityLog::getActionLabelAttribute 1단계(플러그인 lang 전체 액션 키)가 이 배열을 먼저 해석.
'action' => [
'sirsoft-message_bizppurio' => [
'alimtalk_template' => [
'create' => '알림톡 템플릿 등록',
'update' => '알림톡 템플릿 수정',
'delete' => '알림톡 템플릿 삭제',
'request' => '알림톡 템플릿 검수 요청',
'cancel_request' => '알림톡 템플릿 검수 요청 취소',
'stop' => '알림톡 템플릿 중지',
'reuse' => '알림톡 템플릿 중지 해제',
'cancel_approval' => '알림톡 템플릿 승인 취소',
'release' => '알림톡 템플릿 휴면 해제',
],
],
],
// description 본문 — 표준 패턴 activity_log.description.X (DescriptionResolver 해석 대상).
'description' => [
'alimtalk_template_create' => '알림톡 템플릿 등록 (:template_name)',
'alimtalk_template_update' => '알림톡 템플릿 수정 (:template_code)',
'alimtalk_template_delete' => '알림톡 템플릿 삭제 (:template_code)',
'alimtalk_template_request' => '알림톡 템플릿 검수 요청 (:template_code)',
'alimtalk_template_cancel_request' => '알림톡 템플릿 검수 요청 취소 (:template_code)',
'alimtalk_template_stop' => '알림톡 템플릿 중지 (:template_code)',
'alimtalk_template_reuse' => '알림톡 템플릿 중지 해제 (:template_code)',
'alimtalk_template_cancel_approval' => '알림톡 템플릿 승인 취소 (:template_code)',
'alimtalk_template_release' => '알림톡 템플릿 휴면 해제 (:template_code)',
],
];
@@ -73,5 +73,55 @@ return [
'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.',
'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.',
'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.',
'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.',
],
// 알림톡 템플릿 관리 (Phase 5)
'template' => [
// 액션 결과 메시지
'created' => '알림톡 템플릿을 등록했습니다.',
'created_requested' => '알림톡 템플릿을 등록하고 검수를 요청했습니다.',
'updated' => '알림톡 템플릿을 수정했습니다.',
'deleted' => '알림톡 템플릿을 삭제했습니다.',
'requested' => '검수를 요청했습니다.',
'request_canceled' => '검수 요청을 취소했습니다.',
'stopped' => '템플릿을 중지했습니다.',
'resumed' => '템플릿 중지를 해제했습니다.',
'approval_canceled' => '승인을 취소했습니다.',
'released' => '휴면을 해제했습니다.',
// 상태 배지 (serviceStatus)
'status' => [
'sendable' => '발송가능',
'inspecting' => '검수중',
'rejected' => '반려',
'uninspected' => '미검수',
'stopped' => '중지',
'blocked' => '차단',
'dormant' => '휴면',
'unknown' => '알 수 없음',
],
// 템플릿 유형 (templateEmphasizeType)
'emphasize_type' => [
'none' => '기본형',
'text' => '강조표기형',
'image' => '이미지형',
'item_list' => '아이템리스트형',
],
// 검증 속성 라벨 (FormRequest attributes)
'fields' => [
'name' => '템플릿명',
'content' => '템플릿 내용',
'category' => '카테고리',
'emphasize_type' => '템플릿 유형',
'code' => '템플릿 코드',
'title' => '강조표기 문구',
'subtitle' => '보조 문구',
'image' => '이미지',
'image_name' => '이미지 파일명',
'image_url' => '이미지 링크',
],
],
];
@@ -4,6 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
@@ -11,6 +14,7 @@
"@types/node": "^22.0.0",
"jsdom": "^25.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.14",
"vitest": "^2.1.0"
}
}
@@ -18,6 +18,13 @@
},
"github_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio",
"github_changelog_url": "https://github.com/gnuboard/g7-plugin-sirsoft-message_bizppurio/blob/main/CHANGELOG.md",
"assets": {
"js": {
"entry": "resources/js/index.ts",
"output": "dist/js/plugin.iife.js"
},
"handlers": true
},
"loading": {
"strategy": "global",
"priority": 100
@@ -34,77 +34,13 @@ class Plugin extends AbstractPlugin
}
/**
* 관리자 메뉴 정의
* 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리.
*
* 코어 PluginManager 는 모듈과 달리 plugin 의 getAdminMenus() 를 자동 호출하지 않으므로
* 본 클래스의 activate()/deactivate()/uninstall() lifecycle hook 에서 직접 sync 한다.
* 멱등성: helper.syncMenu 가 upsert 패턴이라 재활성화 시에도 row 중복 없이 그대로 복원.
*
* 최상위 「메시지 발송 관리」 아래에 알림톡 템플릿 관리·발송 이력 하위 메뉴를 둔다.
* (하위 페이지는 Phase 4·5 에서 라우트/레이아웃과 함께 실물화)
*
* @return array<int, array<string, mixed>>
*/
public function getAdminMenus(): array
{
return [
[
'name' => ['ko' => '메시지 발송 관리', 'en' => 'Messaging'],
'slug' => 'sirsoft-message_bizppurio',
'url' => null,
'icon' => 'fas fa-paper-plane',
'order' => 55,
'children' => [
[
'name' => ['ko' => '알림톡 템플릿 관리', 'en' => 'Alimtalk Templates'],
'slug' => 'sirsoft-message_bizppurio-alimtalk-templates',
'url' => '/admin/plugins/sirsoft-message_bizppurio/alimtalk-templates',
'icon' => 'fas fa-comment-dots',
'order' => 1,
'permission' => 'sirsoft-message_bizppurio.messaging.view',
],
[
'name' => ['ko' => '발송 이력', 'en' => 'Dispatch History'],
'slug' => 'sirsoft-message_bizppurio-dispatches',
'url' => '/admin/plugins/sirsoft-message_bizppurio/dispatches',
'icon' => 'fas fa-clock-rotate-left',
'order' => 2,
'permission' => 'sirsoft-message_bizppurio.messaging.view',
],
],
],
];
}
/**
* 플러그인 활성화 — 관리자 메뉴 자동 등록.
*
* 모듈은 ModuleManager 가 getAdminMenus() 를 자동 처리하지만 플러그인은
* PluginManager 가 그 책임을 위임하므로 본 메서드에서 helper 를 직접 호출한다.
* syncMenu 가 신규 메뉴 생성 시 admin 역할을 자동 부여하므로 별도 역할 처리는 불필요.
*
* @return bool 활성화 성공 여부
*/
public function activate(): bool
{
$helper = app(ExtensionMenuSyncHelper::class);
foreach ($this->getAdminMenus() as $menuData) {
$helper->syncMenuRecursive(
$menuData,
ExtensionOwnerType::Plugin,
$this->getIdentifier(),
);
}
return true;
}
/**
* 플러그인 비활성화 — 관리자 메뉴 일괄 제거.
*
* cleanupStaleMenus 에 currentSlugs=[] 를 넘겨 본 플러그인 소속 메뉴 전체를 삭제한다.
* 자식 메뉴 + role_menus 피벗 정리는 helper 가 cascade 처리.
* 이 플러그인은 관리자 메뉴를 만들지 않는다(화면 배치 결정 2026-07-14 — 진입은 코어
* 소유 설정 페이지 하나로 통일). 다만 이전 버전(getAdminMenus 사용 시기)에 생성된
* 메뉴 row 가 DB 에 남아 있을 수 있어, 비활성화 시 currentSlugs=[] 로 cleanupStaleMenus
* 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로
* 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리.
*
* @return bool 비활성화 성공 여부
*/
@@ -120,7 +56,7 @@ class Plugin extends AbstractPlugin
}
/**
* 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리).
* 플러그인 제거 — 메뉴 잔재 안전망 (정상 흐름은 deactivate 가 먼저 처리).
*
* @return bool 제거 성공 여부
*/
@@ -0,0 +1,236 @@
/**
* 비즈뿌리오 메시징 플러그인 알림톡 템플릿 관리 탭 구조 검증 (Phase 5, §6-3·6-4)
*
* plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 관리 화면을 검증한다.
* - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(activeSettingsTab)
* - 목록 서브뷰(상태필터·검색·상태배지·상세) / 폼 서브뷰(유형별 필드·변수삽입)
* - 상세 모달 / readiness 안내 / 데이터소스 / i18n 정합
*
* 독립 페이지·메뉴 없음(⚑ 결정 2·3): 설정 페이지 탭으로만 진입.
*/
import { describe, it, expect } from 'vitest';
import layout from '../../../layouts/admin/plugin_settings.json';
import ko from '../../../lang/ko.json';
import en from '../../../lang/en.json';
import { findById, collectI18nKeys, type AnyNode } from './helpers';
const root = layout as unknown as AnyNode;
describe('alimtalk templates — 탭 네비게이션', () => {
it('탭 네비게이션에 환경설정·알림톡 템플릿 탭 버튼이 있다', () => {
const nav = findById(root, 'settings_tabs');
expect(nav).toBeTruthy();
expect(findById(nav, 'tab_connection')).toBeTruthy();
expect(findById(nav, 'tab_templates')).toBeTruthy();
});
it('알림톡 탭 버튼이 query.tab=templates 로 URL 을 갱신하고 목록을 refetch 한다', () => {
const raw = JSON.stringify(findById(root, 'tab_templates'));
expect(raw).toContain('replaceUrl');
expect(raw).toContain('"tab":"templates"');
expect(raw).toContain('"templateView":"list"');
expect(raw).toContain('alimtalk_templates');
});
it('탭 패널이 query.tab 으로 배타 전환된다 (새로고침에도 유지)', () => {
const connection = findById(root, 'connection_tab_panel');
const templates = findById(root, 'templates_tab_panel');
expect((connection as { if?: string }).if).toContain("query.tab ?? 'connection') === 'connection'");
expect((templates as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
});
it('init_actions 가 templates 탭 진입 시 목록을 자동 로드한다 (새로고침 복원)', () => {
const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? [];
const refetch = inits.find((a) => a.handler === 'refetchDataSource');
expect(refetch).toBeTruthy();
expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
});
});
describe('alimtalk templates — 데이터소스', () => {
it('템플릿 목록·카테고리 데이터소스가 admin API 를 조회한다', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const list = sources.find((s) => s.id === 'alimtalk_templates');
const cats = sources.find((s) => s.id === 'alimtalk_categories');
expect(list?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates');
expect(list?.auto_fetch).toBe(false);
expect(cats?.endpoint).toContain('/alimtalk-templates/categories');
});
});
describe('alimtalk templates — 목록 서브뷰', () => {
it('목록 서브뷰가 templateView=list 조건으로 표시된다', () => {
const view = findById(root, 'templates_list_view');
expect((view as { if?: string }).if).toContain("templateView ?? 'list') === 'list'");
});
it('상태 필터 Select 와 검색 입력이 있다', () => {
const toolbar = findById(root, 'templates_toolbar');
const raw = JSON.stringify(toolbar);
expect(raw).toContain('templateStatus');
expect(raw).toContain('templateKeyword');
});
it('빈 목록에서도 헤더 있는 표(카드)를 항상 표시하고 데이터 유무로 Tbody 를 분기한다', () => {
const card = findById(root, 'templates_table_card');
expect(card).toBeTruthy();
const raw = JSON.stringify(card);
// 표 헤더는 항상 존재
expect(raw).toContain('columns.name');
expect(raw).toContain('columns.status');
// 데이터 유무 분기 (빈 상태 안내 + 목록 iteration)
expect(raw).toContain('.length > 0');
expect(raw).toContain('.length === 0');
expect(raw).toContain('templates.list.empty');
});
it('표가 templates 목록을 iteration 으로 렌더한다', () => {
const raw = JSON.stringify(findById(root, 'templates_table_card'));
expect(raw).toContain('alimtalk_templates?.data?.templates');
expect(raw).toContain('"item_var":"tpl"');
expect(raw).toContain('tpl.templateName');
expect(raw).toContain('status_badge');
});
it('[새 템플릿] 버튼이 폼 서브뷰로 전환하고 폼을 초기화한다', () => {
const raw = JSON.stringify(findById(root, 'templates_toolbar'));
expect(raw).toContain('"templateView":"form"');
expect(raw).toContain('templateEmphasizeType');
});
});
describe('alimtalk templates — 폼 서브뷰', () => {
it('폼 서브뷰가 templateView=form 조건으로 표시된다', () => {
const view = findById(root, 'templates_form_view');
expect((view as { if?: string }).if).toContain("templateView === 'form'");
});
it('공통 필드(이름·본문)가 수동바인딩(value+onChange)으로 존재한다', () => {
const raw = JSON.stringify(findById(root, 'templates_form_view'));
// 이름: value 바인딩 + templateForm.templateName setState (자동바인딩 name 대신)
expect(raw).toContain('templateForm.templateName');
expect(raw).toContain('_local.templateForm?.templateName');
// 본문: templateForm.templateContent 수동바인딩
expect(raw).toContain('templateForm.templateContent');
});
it('강조표기형(TEXT) 필드가 유형 조건으로 표시된다', () => {
const text = findById(root, 'template_text_fields');
expect((text as { if?: string }).if).toContain("templateEmphasizeType === 'TEXT'");
const raw = JSON.stringify(text);
expect(raw).toContain('templateTitle');
expect(raw).toContain('templateSubtitle');
});
it('이미지형(IMAGE) 필드가 유형 조건으로 표시된다', () => {
const image = findById(root, 'template_image_fields');
expect((image as { if?: string }).if).toContain("templateEmphasizeType === 'IMAGE'");
const raw = JSON.stringify(image);
expect(raw).toContain('templateImageName');
expect(raw).toContain('templateImageUrl');
});
it('유형 선택 버튼이 1차 지원 3종(NONE/IMAGE/TEXT)만 노출하고 ITEM_LIST 는 없다', () => {
const raw = JSON.stringify(findById(root, 'templates_form_view'));
// 버튼 방식: setState 로 templateEmphasizeType 을 직접 설정
expect(raw).toContain('"templateForm.templateEmphasizeType":"NONE"');
expect(raw).toContain('"templateForm.templateEmphasizeType":"IMAGE"');
expect(raw).toContain('"templateForm.templateEmphasizeType":"TEXT"');
expect(raw).not.toContain('ITEM_LIST');
});
it('변수 버튼이 insertVariable 핸들러로 본문 상태(statePath)에 삽입한다', () => {
const raw = JSON.stringify(findById(root, 'template_variables'));
expect(raw).toContain('sirsoft-message_bizppurio.insertVariable');
expect(raw).toContain('"target":"templateContent"');
expect(raw).toContain('"statePath":"templateForm.templateContent"');
});
it('이벤트별 변수 참고표가 토글 없이 항상 표시된다(인라인 배열 iteration)', () => {
const raw = JSON.stringify(findById(root, 'template_variables'));
// 참고표 iteration 이 인라인 배열(varref lang 라벨 + 변수 예시)을 렌더
expect(raw).toContain('templates.varref.welcome');
expect(raw).toContain('"item_var":"vref"');
// showVarRef 토글 조건이 없어야 함(항상 표시)
expect(raw).not.toContain('showVarRef');
});
it('선택 항목이 계획서 §6-4 6개(부가정보·미리보기·버튼·바로연결·대표링크·보안) 체크펼침으로 존재하고 헤더는 없다', () => {
const raw = JSON.stringify(findById(root, 'template_optional'));
expect(raw).toContain('templateForm.templateExtra');
expect(raw).toContain('templateForm.templatePreviewMessage');
expect(raw).toContain('templateForm.buttons');
expect(raw).toContain('templateForm.quickReplies');
expect(raw).toContain('templateForm.templateRepresentLink');
expect(raw).toContain('templateForm.securityFlag');
// 각 항목 체크박스 토글(_local.opt*)
expect(raw).toContain('optExtra');
expect(raw).toContain('optButton');
expect(raw).toContain('optLink');
// 헤더(ITEM_LIST 전용, §6-4 선택항목 아님)는 제거됨
expect(raw).not.toContain('templateForm.templateHeader');
});
it('템플릿 코드(자동/직접)·카테고리 2단 드롭다운·이미지 파일첨부가 존재한다', () => {
const codeField = JSON.stringify(findById(root, 'template_code_field'));
expect(codeField).toContain('templateCodeMode');
expect(codeField).toContain('"auto"');
expect(codeField).toContain('"manual"');
const catField = JSON.stringify(findById(root, 'template_category_field'));
expect(catField).toContain('categoryGroup');
expect(catField).toContain('templateForm.categoryCode');
expect(catField).toContain('alimtalk_categories');
const imgField = JSON.stringify(findById(root, 'template_image_fields'));
expect(imgField).toContain('"type":"file"');
expect(imgField).toContain('sirsoft-message_bizppurio.uploadTemplateImage');
expect(imgField).toContain('templateImageUrl');
});
it('저장 버튼이 [등록]과 [등록 후 검수요청] 2개로 분리된다', () => {
const raw = JSON.stringify(findById(root, 'templates_form_view'));
expect(raw).toContain('"method":"POST"');
expect(raw).toContain('_local.templateForm');
expect(raw).toContain('"templateView":"list"');
// 등록 후 검수요청: requestInspection 플래그를 body 에 실음
expect(raw).toContain('requestInspection');
expect(raw).toContain('save_register');
expect(raw).toContain('save_and_request');
});
});
describe('alimtalk templates — 상세 모달 & readiness', () => {
it('상세 모달이 정의되어 있다', () => {
const modals = (root as { modals?: AnyNode[] }).modals ?? [];
const detail = modals.find((m) => m.id === 'alimtalk_template_detail_modal');
expect(detail).toBeTruthy();
});
it('readiness 안내가 api_key/sender_key 미설정 시 조건부로 표시된다', () => {
const readiness = findById(root, 'templates_readiness');
const cond = (readiness as { if?: string }).if ?? '';
expect(cond).toContain('api_key');
expect(cond).toContain('sender_key');
});
});
describe('alimtalk templates — i18n 정합', () => {
it('레이아웃 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => {
const keys = collectI18nKeys(layout);
const resolve = (dict: Record<string, unknown>, path: string): unknown =>
path.split('.').reduce<unknown>((acc, seg) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[seg];
}
return undefined;
}, dict);
for (const raw of keys) {
const path = raw.replace('$t:sirsoft-message_bizppurio.', '');
expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy();
expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy();
}
});
});
@@ -47,8 +47,8 @@ describe('plugin_settings.json — 레이아웃 메타/권한', () => {
});
describe('plugin_settings.json — 자동바인딩', () => {
it('컨테이너가 dataKey=form + trackChanges 로 자동바인딩한다', () => {
const container = findById(root, 'plugin_settings_content');
it('환경설정 탭 패널이 dataKey=form + trackChanges 로 자동바인딩한다', () => {
const container = findById(root, 'connection_tab_panel');
expect(container).toBeTruthy();
expect((container as { dataKey?: string }).dataKey).toBe('form');
expect((container as { trackChanges?: boolean }).trackChanges).toBe(true);
@@ -154,7 +154,13 @@ describe('plugin_settings.json — 저장 버튼', () => {
it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => {
const handlers = collectHandlers(layout);
const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView', 'copyToClipboard'];
const allowed = [
'apiCall', 'setState', 'toast', 'navigate', 'sequence',
'refetchDataSource', 'scrollIntoView', 'copyToClipboard',
'openModal', 'closeModal', 'replaceUrl',
'sirsoft-message_bizppurio.insertVariable',
'sirsoft-message_bizppurio.uploadTemplateImage',
];
for (const h of handlers) {
expect(allowed).toContain(h);
}
@@ -0,0 +1,14 @@
/**
* sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.
*
* 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인
* 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable
*/
import { insertVariableHandler } from './insertVariable';
import { uploadTemplateImageHandler } from './uploadTemplateImage';
export const handlerMap = {
insertVariable: insertVariableHandler,
uploadTemplateImage: uploadTemplateImageHandler,
} as const;
@@ -0,0 +1,103 @@
/**
* insertVariable 핸들러
*
* 알림톡 템플릿 등록/수정 화면에서 "사용 가능 변수" 버튼을 클릭하면, 대상 본문의 현재
* 커서 위치에 `#{변수}` 를 삽입한다(D8). 값 자체는 G7 상태(`_local.<statePath>`)를 SSoT
* 로 삼아 setLocal 로 갱신한다. DOM 값을 직접 쓰지 않으므로 자동/수동 바인딩 충돌
* (삽입 후 다음 렌더에서 상태값으로 덮여 사라지는 문제)이 발생하지 않는다.
*
* 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.
*
* 레이아웃 JSON 사용 예:
* {
* "handler": "sirsoft-message_bizppurio.insertVariable",
* "params": { "variable": "name", "target": "templateContent", "statePath": "templateForm.templateContent" }
* }
*/
import type { ActionContext, ActionWithParams } from '../types';
const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {
log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),
warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),
error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),
};
/** 삽입 대상 본문 필드(name) 기본값 */
const DEFAULT_TARGET = 'templateContent';
/** 상태 경로 기본값 (_local 하위) */
const DEFAULT_STATE_PATH = 'templateForm.templateContent';
/**
* 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.
*
* @param action 액션 객체(params.variable, params.target, params.statePath)
* @param context 액션 컨텍스트(state 스냅샷)
*/
export function insertVariableHandler(
action: ActionWithParams,
context: ActionContext,
): void {
const G7Core = (window as any).G7Core;
const params = action.params ?? {};
const variable = String(params.variable ?? '').trim();
const target = String(params.target ?? DEFAULT_TARGET);
const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);
if (variable === '') {
logger.warn('삽입할 변수명이 지정되지 않았습니다.');
return;
}
if (!G7Core?.state?.getLocal || !G7Core?.state?.setLocal) {
logger.error('G7Core.state API 를 사용할 수 없습니다.');
return;
}
// 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getLocal.
const localState = (context as any)?.state ?? G7Core.state.getLocal() ?? {};
const current = String(readPath(localState, statePath) ?? '');
const token = `#{${variable}}`;
// 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.
const field = document.querySelector<HTMLTextAreaElement | HTMLInputElement>(
`textarea[name="${target}"], input[name="${target}"]`,
);
const domValue = field?.value ?? '';
// DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.
const inSync = domValue === current;
const start = inSync ? (field?.selectionStart ?? current.length) : current.length;
const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;
const next = current.slice(0, start) + token + current.slice(end);
G7Core.state.setLocal({ [statePath]: next });
// 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).
if (field) {
const caret = start + token.length;
requestAnimationFrame(() => {
try {
field.focus();
field.setSelectionRange(caret, caret);
} catch {
// setSelectionRange 미지원 input — 무시
}
});
}
logger.log(`변수 삽입: ${token} → ${statePath}`);
}
/**
* dot notation 경로로 객체에서 값을 읽습니다. (예: "templateForm.templateContent")
*
* @param obj 대상 객체
* @param path dot 경로
* @return 경로 값(없으면 undefined)
*/
function readPath(obj: Record<string, any>, path: string): unknown {
return path.split('.').reduce<any>((acc, key) => (acc == null ? undefined : acc[key]), obj);
}
@@ -0,0 +1,98 @@
/**
* uploadTemplateImage 핸들러
*
* 이미지형 알림톡 템플릿 등록 화면에서 파일 선택(change) 시, 선택한 이미지를 플러그인의
* 이미지 업로드 API(카카오 서버 업로드 위임)로 전송하고, 반환된 카카오 이미지 URL 을
* `_local.templateForm.templateImageUrl` 에, 파일명을 `templateImageName` 에 설정한다.
*
* 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다.
* 진행 상태는 `_local.imageUploading`, 오류는 `_local.imageUploadError` 로 노출한다.
*
* 레이아웃 JSON 사용 예:
* {
* "type": "change",
* "handler": "sirsoft-message_bizppurio.uploadTemplateImage"
* }
* (대상 file input 의 change 이벤트에 바인딩; $event.target.files[0] 를 사용)
*/
import type { ActionContext, ActionWithParams } from '../types';
const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? {
log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args),
warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args),
error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args),
};
const UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/image';
/**
* 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다.
*
* @param action 액션 객체(미사용 params)
* @param context 액션 컨텍스트($event 로 파일 접근)
*/
export async function uploadTemplateImageHandler(
action: ActionWithParams,
context: ActionContext,
): Promise<void> {
const G7Core = (window as any).G7Core;
if (!G7Core?.state?.setLocal) {
logger.error('G7Core.state.setLocal 을 사용할 수 없습니다.');
return;
}
const event = (context?.event ?? (action as any)?.event) as Event | undefined;
const input = event?.target as HTMLInputElement | undefined;
const file = input?.files?.[0];
if (!file) {
return;
}
G7Core.state.setLocal({ imageUploading: true, imageUploadError: null });
try {
const token = localStorage.getItem('auth_token') ?? '';
const form = new FormData();
form.append('image', file, file.name);
const res = await fetch(UPLOAD_URL, {
method: 'POST',
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: form,
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json?.success === false) {
const message = json?.errors?.kakao_message || json?.message || '이미지 업로드에 실패했습니다.';
G7Core.state.setLocal({ imageUploading: false, imageUploadError: message });
logger.warn('이미지 업로드 실패:', message);
return;
}
const url = json?.data?.image_url ?? '';
const name = json?.data?.image_name ?? file.name;
G7Core.state.setLocal({
imageUploading: false,
imageUploadError: null,
'templateForm.templateImageUrl': url,
'templateForm.templateImageName': name,
});
logger.log('이미지 업로드 완료:', url);
} catch (e) {
G7Core.state.setLocal({ imageUploading: false, imageUploadError: String(e) });
logger.error('이미지 업로드 예외:', e);
} finally {
// 같은 파일 재선택이 가능하도록 input 값 초기화
if (input) {
input.value = '';
}
}
}
@@ -0,0 +1,94 @@
/**
* sirsoft-message_bizppurio 플러그인 엔트리포인트.
*
* 플러그인 활성화 시 자동 로드되어 커스텀 핸들러(insertVariable)를 ActionDispatcher 에
* 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.
*/
import { handlerMap } from './handlers';
const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
const logger = ((window as any).G7Core?.createLogger?.(`Plugin:${PLUGIN_IDENTIFIER}`)) ?? {
log: (...args: unknown[]) => console.log(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
warn: (...args: unknown[]) => console.warn(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
error: (...args: unknown[]) => console.error(`[Plugin:${PLUGIN_IDENTIFIER}]`, ...args),
};
/**
* handlerMap 의 모든 핸들러를 ActionDispatcher 에 등록합니다.
*
* @param dispatcher 코어 ActionDispatcher 인스턴스
*/
function register(dispatcher: any): void {
Object.entries(handlerMap).forEach(([name, handler]) => {
dispatcher.registerHandler(`${PLUGIN_IDENTIFIER}.${name}`, handler, {
category: 'plugin',
source: PLUGIN_IDENTIFIER,
});
});
logger.log(
`${Object.keys(handlerMap).length} handler(s) registered:`,
Object.keys(handlerMap).map(name => `${PLUGIN_IDENTIFIER}.${name}`),
);
}
/**
* ActionDispatcher 준비 후 핸들러를 등록합니다.
*
* 최초 로드 시 ActionDispatcher 가 아직 없을 수 있으므로 짧게 재시도한다.
*
* @param retry ActionDispatcher 부재 시 재시도 여부
*/
function registerHandlers(retry: boolean): void {
const dispatcher = (window as any).G7Core?.getActionDispatcher?.();
if (dispatcher) {
register(dispatcher);
return;
}
if (!retry) {
logger.warn('ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.');
return;
}
let count = 0;
const max = 50; // 최대 5초 (50 * 100ms)
const tick = () => {
const found = (window as any).G7Core?.getActionDispatcher?.();
if (found) {
register(found);
return;
}
if (++count <= max) {
setTimeout(tick, 100);
} else {
logger.error('ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.');
}
};
tick();
}
/**
* 플러그인 초기화 — DOM 준비 후 핸들러 등록.
*/
export function initPlugin(): void {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => registerHandlers(true));
} else {
const hasDispatcher = !!(window as any).G7Core?.getActionDispatcher?.();
registerHandlers(!hasDispatcher);
}
}
initPlugin();
if (typeof window !== 'undefined') {
(window as any).__SirsoftMessageBizppurio = {
identifier: PLUGIN_IDENTIFIER,
handlers: Object.keys(handlerMap),
initPlugin,
};
}
@@ -0,0 +1,28 @@
/**
* sirsoft-message_bizppurio 플러그인 프론트 타입 정의.
*/
/**
* 액션 컨텍스트 인터페이스.
*
* ActionDispatcher 가 커스텀 핸들러 실행 시 전달하는 컨텍스트다.
*/
export interface ActionContext {
/** 현재 로컬 상태 가져오기 */
getLocalState?: () => Record<string, any>;
/** 로컬 상태 업데이트 */
setLocalState?: (updates: Record<string, any>) => void;
/** 이벤트 객체 */
event?: Event;
/** 데이터 컨텍스트 */
dataContext?: Record<string, any>;
}
/**
* 커스텀 핸들러 액션 객체(공통).
*/
export interface ActionWithParams {
handler: string;
params?: Record<string, any>;
[key: string]: any;
}
@@ -56,6 +56,149 @@
"note": "Once registered, delivery results are recorded in the history so you can verify actual delivery. Before registration only the send request is confirmed; the final result (success/failure) is not updated.",
"copy": "Copy",
"copied": "Report endpoint copied to clipboard."
},
"tabs": {
"connection": "Settings",
"templates": "Alimtalk Templates"
}
},
"templates": {
"title": "Alimtalk Templates",
"description": "Register, inspect, and manage KakaoTalk alimtalk templates. Only approved templates can be used for notification bindings.",
"readiness": {
"title": "The following settings are required to use alimtalk templates",
"go_settings": "Go to settings",
"missing_label": "Missing",
"api_key_missing": "Kakao management API key",
"sender_key_missing": "Alimtalk sender profile key",
"note": "Enter the items above on the Settings tab to browse and register templates. Actual delivery requires turning off test mode and using templates approved by Kakao."
},
"list": {
"new": "New Template",
"refresh": "Refresh",
"search_placeholder": "Search by name (2-50 chars)",
"filter_all": "All statuses",
"empty": "No alimtalk templates registered.",
"empty_hint": "Register an alimtalk template with the [New Template] button.",
"load_failed": "Failed to load templates. Check the sender profile key and API key.",
"columns": {
"name": "Name",
"code": "Code",
"status": "Status",
"actions": "Actions"
}
},
"status": {
"sendable": "Sendable",
"inspecting": "Inspecting",
"rejected": "Rejected",
"uninspected": "Not inspected",
"stopped": "Stopped",
"blocked": "Blocked",
"dormant": "Dormant",
"unknown": "Unknown"
},
"actions": {
"edit": "Edit",
"delete": "Delete",
"request": "Request",
"cancel_request": "Cancel request",
"stop": "Stop",
"reuse": "Resume",
"cancel_approval": "Cancel approval",
"release": "Release",
"detail": "Detail"
},
"form": {
"title_new": "Register Alimtalk Template",
"title_edit": "Edit Alimtalk Template",
"back_to_list": "Back to list",
"sender_key": "Sender profile key",
"code": "Template code",
"code_auto": "Auto-generate",
"code_manual": "Enter manually",
"code_hint": "Letters, digits, underscore(_), hyphen(-), max 30 chars",
"name": "Template name",
"name_hint": "Management name (not part of content), max 200 chars",
"category": "Category",
"category_group": "Group",
"category_item": "Item",
"emphasize_type": "Template type",
"type_none": "Basic",
"type_image": "Image",
"type_text": "Highlighted",
"type_item_list_notice": "Advanced templates such as item-list should be registered on the Bizppurio website. Templates registered there are also available for listing and bindings.",
"goto_bizppurio": "Bizppurio website",
"image_name": "Image file name",
"image_url": "Image URL",
"image_upload": "Attach image",
"image_upload_hint": "jpg/png / max 500KB / at least 500px wide (2:1 to 3:4 ratio)",
"image_uploading": "Uploading image...",
"image_remove": "Remove image",
"image_maker_notice": "The image maker (auto-generation tool) is provided in the Bizppurio console.",
"title_field": "Highlight title",
"title_hint": "Only content present in the body, max 23 chars (variables allowed)",
"subtitle_field": "Highlight subtitle",
"subtitle_hint": "Only content present in the body, max 18 chars (no variables)",
"content": "Template content",
"content_placeholder": "Enter the body. Insert variables with #{variable}.",
"content_hint": "Max 1300 chars",
"variables_title": "Insert variable",
"variables_help": "Insert #{variable} into the body; when you bind this template to a notification, it is replaced with the real value (member name, order number, etc.). Below are example variables per notification — insert the ones matching the notification you will bind.",
"variables_quick": "Common variables (click to insert at cursor)",
"variables_ref_toggle": "Variable reference by notification",
"variables_ref_event": "Notification",
"variables_ref_vars": "Provided variables",
"optional_title": "Optional fields",
"optional_toggle": "Expand optional fields (extra info, buttons, etc.)",
"optional_help": "Check only the items you need to reveal their inputs. Buttons and quick replies assume a single web link (WL).",
"button": "Button",
"button_name": "Button label",
"button_url": "Mobile link URL",
"button_hint": "A web-link button shown at the bottom of the message.",
"quick_reply": "Quick reply",
"quick_reply_hint": "Quick reply (web link) used on consult-talk channels.",
"represent_link": "Represent link",
"link_pc": "PC web link",
"link_mo": "Mobile web link",
"preview_message": "Preview message",
"preview_message_hint": "Summary shown in the chat list (max 40 chars).",
"extra": "Extra info",
"extra_hint": "Supplementary text outside the body such as channel-add or ad notices (registers the message type as extra-info type).",
"security_flag": "Secure template (OTP, etc.)",
"security_flag_hint": "Turn on for secure messages such as OTP. The message text is hidden on devices other than the sending device.",
"preview_title": "Preview",
"preview_empty": "Enter the body to see a preview here.",
"save_register": "Register",
"save_register_hint": "Registers as a draft only. To send, request inspection from the list and get Kakao approval.",
"save_and_request": "Register and request inspection",
"save_and_request_hint": "Registers and requests Kakao inspection at once. You cannot edit after requesting."
},
"detail": {
"title": "Template detail",
"close": "Close"
},
"confirm": {
"delete": "Delete this template?",
"request": "Request inspection? You cannot edit after requesting.",
"cancel_request": "Cancel the inspection request?",
"stop": "Stop this template? It cannot be used for delivery once stopped.",
"cancel_approval": "Cancel the approval? Re-inspection will be required."
},
"toast": {
"created": "The alimtalk template has been registered.",
"created_requested": "The alimtalk template has been registered and inspection requested.",
"updated": "The alimtalk template has been updated.",
"deleted": "The alimtalk template has been deleted.",
"action_success": "The request has been processed.",
"action_failed": "Failed to process the request."
},
"varref": {
"welcome": "Welcome (signup)",
"reset_password": "Password reset",
"order_confirmed": "Order confirmed",
"order_shipped": "Order shipped",
"new_comment": "New comment"
}
}
}
@@ -56,6 +56,149 @@
"note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.",
"copy": "복사",
"copied": "리포트 수신 주소가 클립보드에 복사되었습니다."
},
"tabs": {
"connection": "환경설정",
"templates": "알림톡 템플릿"
}
},
"templates": {
"title": "알림톡 템플릿",
"description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.",
"readiness": {
"title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다",
"go_settings": "환경설정으로 이동",
"missing_label": "미설정 항목",
"api_key_missing": "카카오 관리 API 키",
"sender_key_missing": "알림톡 발신프로필 키",
"note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다."
},
"list": {
"new": "새 템플릿",
"refresh": "새로고침",
"search_placeholder": "템플릿명 검색 (2~50자)",
"filter_all": "전체 상태",
"empty": "등록된 알림톡 템플릿이 없습니다.",
"empty_hint": "[새 템플릿] 버튼으로 알림톡 템플릿을 등록하세요.",
"load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.",
"columns": {
"name": "템플릿명",
"code": "코드",
"status": "상태",
"actions": "관리"
}
},
"status": {
"sendable": "발송가능",
"inspecting": "검수중",
"rejected": "반려",
"uninspected": "미검수",
"stopped": "중지",
"blocked": "차단",
"dormant": "휴면",
"unknown": "알 수 없음"
},
"actions": {
"edit": "수정",
"delete": "삭제",
"request": "검수요청",
"cancel_request": "검수취소",
"stop": "중지",
"reuse": "중지해제",
"cancel_approval": "승인취소",
"release": "휴면해제",
"detail": "상세"
},
"form": {
"title_new": "알림톡 템플릿 등록",
"title_edit": "알림톡 템플릿 수정",
"back_to_list": "목록으로",
"sender_key": "발신프로필 키",
"code": "템플릿 코드",
"code_auto": "자동 생성",
"code_manual": "직접 입력",
"code_hint": "영문·숫자·언더바(_)·하이픈(-), 최대 30자",
"name": "템플릿명",
"name_hint": "관리용 이름 (내용에 포함되지 않음), 최대 200자",
"category": "카테고리",
"category_group": "대분류",
"category_item": "소분류",
"emphasize_type": "템플릿 유형",
"type_none": "기본형",
"type_image": "이미지형",
"type_text": "강조표기형",
"type_item_list_notice": "아이템리스트형 등 고급 템플릿은 비즈뿌리오 홈페이지에서 등록하세요. 홈페이지에서 등록한 템플릿도 목록 조회·연동에서 그대로 사용할 수 있습니다.",
"goto_bizppurio": "비즈뿌리오 홈페이지",
"image_name": "이미지 파일명",
"image_url": "이미지 링크",
"image_upload": "이미지 첨부",
"image_upload_hint": "jpg·png / 최대 500KB / 가로 500px 이상 (가로:세로 2:1~3:4)",
"image_uploading": "이미지 업로드 중...",
"image_remove": "이미지 제거",
"image_maker_notice": "이미지 메이커(자동 생성 도구)는 비즈뿌리오 콘솔에서 제공됩니다.",
"title_field": "강조표기 문구",
"title_hint": "본문에 있는 내용만, 최대 23자 (변수 사용 가능)",
"subtitle_field": "보조 문구",
"subtitle_hint": "본문에 있는 내용만, 최대 18자 (변수 사용 불가)",
"content": "템플릿 내용",
"content_placeholder": "본문을 입력하세요. #{변수} 형식으로 변수를 넣을 수 있습니다.",
"content_hint": "최대 1300자",
"variables_title": "변수 넣기",
"variables_help": "본문에 #{변수} 형식으로 넣으면, 이 템플릿을 알림에 연결할 때 실제 값(회원 이름·주문번호 등)으로 자동 치환됩니다. 아래는 알림별 제공 변수 예시입니다 — 연결할 알림에 맞는 변수를 본문에 넣으세요.",
"variables_quick": "자주 쓰는 변수 (클릭하면 커서 위치에 삽입)",
"variables_ref_toggle": "알림별 제공 변수 참고표",
"variables_ref_event": "알림",
"variables_ref_vars": "제공 변수",
"optional_title": "선택 항목",
"optional_toggle": "선택 항목 펼치기 (부가정보·버튼 등)",
"optional_help": "필요한 항목만 체크하면 입력란이 나타납니다. 버튼·바로연결은 웹링크(WL) 1개 기준입니다.",
"button": "버튼",
"button_name": "버튼명",
"button_url": "모바일 링크 URL",
"button_hint": "메시지 하단에 표시되는 웹링크 버튼입니다.",
"quick_reply": "바로연결",
"quick_reply_hint": "상담톡 채널에서 사용하는 바로연결(웹링크)입니다.",
"represent_link": "대표 링크",
"link_pc": "PC 웹 링크",
"link_mo": "모바일 웹 링크",
"preview_message": "미리보기 메시지",
"preview_message_hint": "채팅 목록에 표시되는 요약 문구 (최대 40자)",
"extra": "부가정보",
"extra_hint": "채널 추가·광고성 안내 등 본문 외 부가 문구 (입력 시 메시지 유형이 부가정보형으로 등록됩니다)",
"security_flag": "템플릿 보안 (OTP 등)",
"security_flag_hint": "OTP 등 보안 메시지일 때 켜세요. 발신 디바이스 외 다른 기기에는 메시지 텍스트가 표시되지 않습니다.",
"preview_title": "미리보기",
"preview_empty": "본문을 입력하면 여기에 미리보기가 표시됩니다.",
"save_register": "등록",
"save_register_hint": "임시 등록만 합니다. 발송하려면 이후 목록에서 검수요청 → 카카오 승인이 필요합니다.",
"save_and_request": "등록 후 검수요청",
"save_and_request_hint": "등록과 동시에 카카오에 검수를 요청합니다. 요청 후에는 수정할 수 없습니다."
},
"detail": {
"title": "템플릿 상세",
"close": "닫기"
},
"confirm": {
"delete": "이 템플릿을 삭제하시겠습니까?",
"request": "검수를 요청하시겠습니까? 요청 후에는 수정할 수 없습니다.",
"cancel_request": "검수 요청을 취소하시겠습니까?",
"stop": "이 템플릿을 중지하시겠습니까? 중지하면 발송에 사용할 수 없습니다.",
"cancel_approval": "승인을 취소하시겠습니까? 취소하면 재검수가 필요합니다."
},
"toast": {
"created": "알림톡 템플릿을 등록했습니다.",
"created_requested": "알림톡 템플릿을 등록하고 검수를 요청했습니다.",
"updated": "알림톡 템플릿을 수정했습니다.",
"deleted": "알림톡 템플릿을 삭제했습니다.",
"action_success": "요청을 처리했습니다.",
"action_failed": "요청 처리에 실패했습니다."
},
"varref": {
"welcome": "회원가입 환영",
"reset_password": "비밀번호 재설정",
"order_confirmed": "결제 완료",
"order_shipped": "배송 시작",
"new_comment": "새 댓글"
}
}
}
@@ -0,0 +1,284 @@
<?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 Illuminate\Http\Request;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreAlimtalkTemplateRequest;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateAlimtalkTemplateRequest;
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UploadTemplateImageRequest;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
/**
* 알림톡 템플릿 관리 컨트롤러 (Phase 5).
*
* 카카오 관리 API(kapi)로 알림톡 템플릿을 실시간 조회·등록·수정·삭제·검수·상태변경한다.
* 저장(binding)은 하지 않으며(Phase 6), 목록/상세는 매 요청 실시간 조회한다.
*
* 권한(라우트 미들웨어):
* - 조회(list/detail/categories/profiles): sirsoft-message_bizppurio.messaging.view
* - 변경(store/update/destroy/검수/상태변경): sirsoft-message_bizppurio.messaging.manage
*
* kapi 실패는 BizppurioApiException 으로 전달되므로, 각 액션에서 catch 하여 카카오가 준
* 실패 사유(message)를 그대로 422 로 반환한다(운영자가 반려/차단 사유를 바로 확인).
*/
class AlimtalkTemplateController extends AdminBaseController
{
/**
* @param AlimtalkTemplateService $service 알림톡 템플릿 서비스
*/
public function __construct(
private readonly AlimtalkTemplateService $service,
) {
parent::__construct();
}
/**
* 알림톡 템플릿 목록을 실시간 조회합니다.
*
* 쿼리: status(templateStatus)·keyword(2~50자)·page·count
*
* @param Request $request HTTP 요청
*/
public function index(Request $request): JsonResponse
{
return $this->guard(function () use ($request) {
$result = $this->service->list([
'status' => $request->query('status'),
'keyword' => $request->query('keyword'),
'page' => $request->query('page'),
'count' => $request->query('count'),
]);
return ResponseHelper::success('messages.success', $result);
});
}
/**
* 알림톡 템플릿 상세를 실시간 조회합니다.
*
* @param string $templateCode 템플릿 코드
*/
public function show(string $templateCode): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'template' => $this->service->detail($templateCode),
]));
}
/**
* 템플릿 등록에 사용할 카테고리 전체를 조회합니다.
*/
public function categories(): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'categories' => $this->service->categories(),
]));
}
/**
* 발신프로필(사용중) 정보를 조회합니다.
*/
public function profiles(): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success('messages.success', [
'profiles' => $this->service->senderProfiles(),
]));
}
/**
* 이미지형 템플릿용 이미지를 업로드하고 카카오 등록 URL 을 반환합니다.
*
* 업로드된 임시 파일을 카카오 이미지 업로드 API 로 전달하고, 반환된 URL 을 프론트가
* templateImageUrl 로 사용한다.
*
* @param UploadTemplateImageRequest $request 이미지 파일 검증 Request
*/
public function uploadImage(UploadTemplateImageRequest $request): JsonResponse
{
return $this->guard(function () use ($request) {
$file = $request->file('image');
$url = $this->service->uploadImage(
$file->getRealPath(),
$file->getClientOriginalName(),
);
return ResponseHelper::success('messages.success', [
'image_url' => $url,
'image_name' => $file->getClientOriginalName(),
]);
});
}
/**
* 알림톡 템플릿을 신규 등록합니다.
*
* @param StoreAlimtalkTemplateRequest $request 유형별 검증 Request
*/
public function store(StoreAlimtalkTemplateRequest $request): JsonResponse
{
return $this->guard(function () use ($request) {
$data = $request->validated();
$requestInspection = (bool) ($data['requestInspection'] ?? false);
$template = $this->service->create($data);
// [등록 후 검수요청]: 등록으로 받은 템플릿 코드로 즉시 검수를 요청한다.
if ($requestInspection) {
$templateCode = (string) ($template['templateCode'] ?? '');
if ($templateCode !== '') {
$this->service->requestInspection($templateCode);
}
return ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.created_requested',
['template' => $template],
);
}
return ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.created',
['template' => $template],
);
});
}
/**
* 알림톡 템플릿을 수정합니다. (대기 + 검수 REG/REJ 상태만)
*
* @param UpdateAlimtalkTemplateRequest $request 유형별 검증 Request
* @param string $templateCode 기존 템플릿 코드
*/
public function update(UpdateAlimtalkTemplateRequest $request, string $templateCode): JsonResponse
{
return $this->guard(fn () => ResponseHelper::success(
'sirsoft-message_bizppurio::messages.template.updated',
['template' => $this->service->update($templateCode, $request->validated())],
));
}
/**
* 알림톡 템플릿을 삭제합니다. (대기 + 검수 REG/REJ 상태만)
*
* @param string $templateCode 템플릿 코드
*/
public function destroy(string $templateCode): JsonResponse
{
return $this->guard(function () use ($templateCode) {
$this->service->delete($templateCode);
return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.deleted');
});
}
/**
* 검수를 요청합니다. (검수 REG 상태만)
*
* @param Request $request HTTP 요청(comment 선택)
* @param string $templateCode 템플릿 코드
*/
public function requestInspection(Request $request, string $templateCode): JsonResponse
{
return $this->guard(function () use ($request, $templateCode) {
$this->service->requestInspection($templateCode, $request->input('comment'));
return ResponseHelper::success('sirsoft-message_bizppurio::messages.template.requested');
});
}
/**
* 검수 요청을 취소합니다. (검수 REQ 상태만)
*
* @param string $templateCode 템플릿 코드
*/
public function cancelRequest(string $templateCode): JsonResponse
{
return $this->statusAction($templateCode, 'cancelRequest', 'request_canceled');
}
/**
* 승인된 템플릿을 중지합니다. (RDY/ACT 상태)
*
* @param string $templateCode 템플릿 코드
*/
public function stop(string $templateCode): JsonResponse
{
return $this->statusAction($templateCode, 'stop', 'stopped');
}
/**
* 중지된 템플릿을 정상으로 되돌립니다. (STP 상태)
*
* @param string $templateCode 템플릿 코드
*/
public function reuse(string $templateCode): JsonResponse
{
return $this->statusAction($templateCode, 'reuse', 'resumed');
}
/**
* 승인을 취소합니다(재검수 가능 상태로 복귀). (RDY/ACT 상태)
*
* @param string $templateCode 템플릿 코드
*/
public function cancelApproval(string $templateCode): JsonResponse
{
return $this->statusAction($templateCode, 'cancelApproval', 'approval_canceled');
}
/**
* 휴면 템플릿을 해제합니다. (DMT 상태)
*
* @param string $templateCode 템플릿 코드
*/
public function release(string $templateCode): JsonResponse
{
return $this->statusAction($templateCode, 'release', 'released');
}
/**
* senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리.
*
* @param string $templateCode 템플릿 코드
* @param string $method 서비스 메서드명
* @param string $messageKey 성공 메시지 키(messages.template.*)
*/
private function statusAction(string $templateCode, string $method, string $messageKey): JsonResponse
{
return $this->guard(function () use ($templateCode, $method, $messageKey) {
$this->service->{$method}($templateCode);
return ResponseHelper::success("sirsoft-message_bizppurio::messages.template.{$messageKey}");
});
}
/**
* kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환합니다.
*
* 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 반려/차단/상태오류 원인을
* 즉시 파악하게 한다. errors 에 result_code 를 실어 프론트가 코드 분기 가능.
*
* @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(),
],
);
}
}
}
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 알림톡 템플릿 등록 검증 Request (D12).
*
* 화면 입력만 검증한다. senderKey·templateMessageType 은 서비스가 채우므로 여기서
* 검증하지 않는다(senderKey=환경설정, messageType=부가정보·버튼 유무 자동 계산).
*
* 유형별 조건부 필수(templateEmphasizeType, 1차 지원 3종):
* - NONE(기본형): 추가 필드 없음
* - IMAGE(이미지형): templateImageName·templateImageUrl 필수
* - TEXT(강조표기형): templateTitle 필수(변수 가능) + templateSubtitle 필수(변수 불가)
* ITEM_LIST(아이템리스트형)는 1차 제외 → 목록에서 배제(홈페이지 안내).
*
* 인증/권한은 라우트 permission 미들웨어(messaging.manage)가 담당하므로 authorize() 는 true 고정.
*/
class StoreAlimtalkTemplateRequest extends FormRequest
{
/**
* 권한 검사는 라우트 미들웨어가 담당 — 항상 통과.
*/
public function authorize(): bool
{
return true;
}
/**
* 알림톡 템플릿 등록 검증 규칙.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// 공통 필수
'templateName' => ['required', 'string', 'max:200'],
'templateContent' => ['required', 'string', 'max:1300'],
'categoryCode' => ['required', 'string', 'max:20'],
'templateEmphasizeType' => ['required', Rule::in(['NONE', 'IMAGE', 'TEXT'])],
// 템플릿 코드(선택, 자동 생성 가능). 영문·숫자·언더바·하이픈, 최대 30자
'templateCode' => ['nullable', 'string', 'max:30', 'regex:/^[A-Za-z0-9_-]+$/'],
// 강조표기형(TEXT) 조건부 필수 — 강조표기 문구(변수 가능) + 보조문구(변수 불가)
'templateTitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:23'],
'templateSubtitle' => ['required_if:templateEmphasizeType,TEXT', 'nullable', 'string', 'max:18'],
// 이미지형(IMAGE) 조건부 필수 — 이미지 파일명 + 링크
'templateImageName' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'max:255'],
'templateImageUrl' => ['required_if:templateEmphasizeType,IMAGE', 'nullable', 'string', 'url', 'max:500'],
// 선택 항목(§6-4): 부가정보 · 미리보기 메시지 · 템플릿 보안
'templatePreviewMessage' => ['nullable', 'string', 'max:40'],
'templateExtra' => ['nullable', 'string', 'max:500'],
'securityFlag' => ['nullable', 'boolean'],
// 등록 후 즉시 검수요청 여부([등록 후 검수요청] 버튼) — 서비스가 소비, kapi payload 제외
'requestInspection' => ['nullable', 'boolean'],
// 버튼(최대 5)
'buttons' => ['nullable', 'array', 'max:5'],
'buttons.*.name' => ['required_with:buttons', 'string', 'max:20'],
'buttons.*.linkType' => ['required_with:buttons', 'string', 'max:4'],
'buttons.*.linkMo' => ['nullable', 'string', 'max:500'],
'buttons.*.linkPc' => ['nullable', 'string', 'max:500'],
'buttons.*.linkAnd' => ['nullable', 'string', 'max:500'],
'buttons.*.linkIos' => ['nullable', 'string', 'max:500'],
// 바로연결(최대 10)
'quickReplies' => ['nullable', 'array', 'max:10'],
'quickReplies.*.name' => ['required_with:quickReplies', 'string', 'max:20'],
'quickReplies.*.linkType' => ['required_with:quickReplies', 'string', 'max:4'],
// 대표 링크(선택) — PC/모바일 웹, 앱 스킴
'templateRepresentLink' => ['nullable', 'array'],
'templateRepresentLink.linkPc' => ['nullable', 'string', 'max:500'],
'templateRepresentLink.linkMo' => ['nullable', 'string', 'max:500'],
'templateRepresentLink.linkAnd' => ['nullable', 'string', 'max:500'],
'templateRepresentLink.linkIos' => ['nullable', 'string', 'max:500'],
];
}
/**
* 검증 속성 라벨(다국어).
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'templateName' => __('sirsoft-message_bizppurio::messages.template.fields.name'),
'templateContent' => __('sirsoft-message_bizppurio::messages.template.fields.content'),
'categoryCode' => __('sirsoft-message_bizppurio::messages.template.fields.category'),
'templateEmphasizeType' => __('sirsoft-message_bizppurio::messages.template.fields.emphasize_type'),
'templateCode' => __('sirsoft-message_bizppurio::messages.template.fields.code'),
'templateTitle' => __('sirsoft-message_bizppurio::messages.template.fields.title'),
'templateSubtitle' => __('sirsoft-message_bizppurio::messages.template.fields.subtitle'),
'templateImageName' => __('sirsoft-message_bizppurio::messages.template.fields.image_name'),
'templateImageUrl' => __('sirsoft-message_bizppurio::messages.template.fields.image_url'),
];
}
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
/**
* 알림톡 템플릿 수정 검증 Request (D12).
*
* 등록과 검증 규칙이 동일하다(유형별 조건부 필수·선택항목 동일). 기존 템플릿 코드는 URL
* path param({templateCode})으로 받으므로 body 에서 검증하지 않는다. 등록 Request 를
* 상속해 규칙 중복을 피한다.
*
* 수정 가능 상태(대기 R + 검수 REG/REJ) 판정은 kapi 가 담당하므로 여기서 검증하지 않는다.
*/
class UpdateAlimtalkTemplateRequest extends StoreAlimtalkTemplateRequest {}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 이미지형 알림톡 템플릿 이미지 업로드 검증 Request.
*
* 카카오 이미지형 템플릿 제한(jpg/png, 최대 500KB, 가로 500px 이상)에 맞춰 파일을 검증한다.
* 실제 크기·비율 제한은 카카오 업로드 API 가 최종 판정하므로 여기서는 확장자·용량만 사전 차단한다.
*
* 인증/권한은 라우트 permission 미들웨어(messaging.manage)가 담당하므로 authorize() 는 true 고정.
*/
class UploadTemplateImageRequest extends FormRequest
{
/**
* 권한 검사는 라우트 미들웨어가 담당 — 항상 통과.
*/
public function authorize(): bool
{
return true;
}
/**
* 이미지 업로드 검증 규칙.
*
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// jpg/png, 최대 500KB (카카오 이미지형 제한)
'image' => ['required', 'file', 'mimes:jpg,jpeg,png', 'max:500'],
];
}
/**
* 검증 속성 라벨(다국어).
*
* @return array<string, string>
*/
public function attributes(): array
{
return [
'image' => __('sirsoft-message_bizppurio::messages.template.fields.image'),
];
}
}
@@ -0,0 +1,581 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Services;
use App\ActivityLog\Traits\ResolvesActivityLogType;
use App\Services\PluginSettingsService;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
/**
* 알림톡 템플릿 관리 서비스 (Phase 5).
*
* 카카오 관리 API(kapi.ppurio.com)를 BizppurioKakaoApiClient 로 위임하여 알림톡 템플릿의
* 실시간 조회·등록·수정·삭제·검수(요청/취소)·상태변경(중지/해제/승인취소)을 수행한다.
* 템플릿은 DB 에 저장하지 않고 매 요청 실시간으로 조회한다(계획서 §6-3).
*
* 이 서비스는 다음 두 가지 도메인 로직을 담당한다.
* - serviceStatus(REG/REQ/REJ/RDY/ACT/DMT/STP/BLK) → 상태 배지 + 상태별 가능 액션 매핑
* - templateMessageType(BA/EX/AD/MI) 백엔드 자동 계산(부가정보·채널추가 버튼 유무로 결정)
*
* 발신프로필 키(senderKey)는 환경설정(sender_key)에서 가져오며, 미설정 시 조회 자체가
* 불가능하므로 화면은 readiness 로 사전 안내한다(§6-3).
*/
class AlimtalkTemplateService
{
use ResolvesActivityLogType;
/** 플러그인 식별자 (manifest 와 일치) */
private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
/** 목록 조회 기본 페이지 크기 */
private const DEFAULT_COUNT = 20;
/**
* serviceStatus → 상태 배지 매핑.
*
* key = kapi serviceStatus, value = ['label_key' => lang key, 'variant' => 배지 색].
* variant 는 프론트가 배지 색상 클래스로 사용한다(green/yellow/red/gray/dark/purple).
*
* @var array<string, array{label_key: string, variant: string}>
*/
private const STATUS_BADGES = [
'RDY' => ['label_key' => 'sendable', 'variant' => 'green'],
'ACT' => ['label_key' => 'sendable', 'variant' => 'green'],
'REQ' => ['label_key' => 'inspecting', 'variant' => 'yellow'],
'REJ' => ['label_key' => 'rejected', 'variant' => 'red'],
'REG' => ['label_key' => 'uninspected', 'variant' => 'gray'],
'STP' => ['label_key' => 'stopped', 'variant' => 'dark'],
'BLK' => ['label_key' => 'blocked', 'variant' => 'dark'],
'DMT' => ['label_key' => 'dormant', 'variant' => 'purple'],
];
/**
* serviceStatus → 상태별 가능 액션(정본 표, §6-3).
*
* @var array<string, array<int, string>>
*/
private const STATUS_ACTIONS = [
'REG' => ['edit', 'delete', 'request'],
'REQ' => ['cancel_request'],
'REJ' => ['edit', 'delete'],
'RDY' => ['stop', 'cancel_approval'],
'ACT' => ['stop', 'cancel_approval'],
'STP' => ['reuse'],
'DMT' => ['release'],
'BLK' => [],
];
/**
* @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트
* @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key)
*/
public function __construct(
private readonly BizppurioKakaoApiClient $kakao,
private readonly PluginSettingsService $pluginSettings,
) {}
/**
* 알림톡 템플릿 목록을 실시간 조회합니다.
*
* @param array<string, mixed> $filters status(templateStatus)·keyword·page·count
* @return array{templates: array<int, array<string, mixed>>, pagination: array<string, int>}
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function list(array $filters = []): array
{
$params = [
'count' => (int) ($filters['count'] ?? self::DEFAULT_COUNT),
'page' => max(1, (int) ($filters['page'] ?? 1)),
];
if (! empty($filters['status'])) {
$params['templateStatus'] = (string) $filters['status'];
}
if (! empty($filters['keyword'])) {
$params['keyword'] = (string) $filters['keyword'];
}
$response = $this->kakao->getTemplateList($this->senderKey(), $params);
$this->assertSuccess($response);
$rows = (array) ($response['data']['list'] ?? $response['data'] ?? []);
return [
'templates' => array_map(fn (array $row) => $this->decorate($row), $rows),
'pagination' => [
'total' => (int) ($response['totalCount'] ?? count($rows)),
'total_page' => (int) ($response['totalPage'] ?? 1),
'current_page' => (int) ($response['currentPage'] ?? $params['page']),
'per_page' => $params['count'],
],
];
}
/**
* 알림톡 템플릿 상세를 실시간 조회합니다.
*
* @param string $templateCode 템플릿 코드
* @return array<string, mixed> 배지·가능 액션이 부가된 템플릿 상세
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function detail(string $templateCode): array
{
$response = $this->kakao->getTemplateDetail($this->senderKey(), $templateCode);
$this->assertSuccess($response);
return $this->decorate((array) ($response['data'] ?? []));
}
/**
* 템플릿 등록에 사용할 카테고리 목록 전체를 조회합니다.
*
* @return array<int, array<string, mixed>> [{code, name, groupName}]
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function categories(): array
{
$response = $this->kakao->request('/v3/kakao/template/category/all');
$this->assertSuccess($response);
return array_values((array) ($response['data'] ?? []));
}
/**
* 발신프로필(사용중) 정보를 조회합니다.
*
* @return array<string, mixed> 발신프로필 응답 data
*
* @throws BizppurioApiException 자격증명 미설정·조회 실패 시
*/
public function senderProfiles(): array
{
$response = $this->kakao->getSenderProfiles();
$this->assertSuccess($response);
return (array) ($response['data'] ?? []);
}
/**
* 이미지형 템플릿용 이미지를 카카오 서버에 업로드하고 등록 URL 을 반환합니다.
*
* 업로드 성공 시 카카오가 반환한 이미지 URL 을 `templateImageUrl` 로 사용한다.
*
* @param string $filePath 임시 업로드 파일 절대 경로
* @param string $fileName 원본 파일명
* @return string 카카오 서버에 등록된 이미지 URL
*
* @throws BizppurioApiException 업로드 실패 시
*/
public function uploadImage(string $filePath, string $fileName): string
{
$response = $this->kakao->uploadTemplateImage($filePath, $fileName);
$this->assertSuccess($response);
return (string) ($response['image'] ?? '');
}
/**
* 알림톡 템플릿을 신규 등록합니다.
*
* senderKey·templateMessageType 은 서비스가 채우며(FormRequest 는 화면 입력만 검증),
* messageType 은 부가정보/채널추가 버튼 유무로 자동 계산한다.
*
* @param array<string, mixed> $data 검증된 템플릿 입력
* @return array<string, mixed> 등록 결과(템플릿 상세)
*
* @throws BizppurioApiException 등록 실패 시
*/
public function create(array $data): array
{
$payload = $this->buildPayload($data);
$response = $this->kakao->request('/v3/kakao/template/add', $payload);
$this->assertSuccess($response);
$this->logActivity('sirsoft-message_bizppurio.alimtalk_template.create', [
'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_create',
'description_params' => ['template_name' => (string) ($data['templateName'] ?? '')],
]);
return (array) ($response['data'] ?? []);
}
/**
* 알림톡 템플릿을 수정합니다. (대기(R) + 검수 REG/REJ 상태만 가능)
*
* @param string $templateCode 기존 템플릿 코드
* @param array<string, mixed> $data 검증된 템플릿 입력
* @return array<string, mixed> 수정 결과(템플릿 상세)
*
* @throws BizppurioApiException 수정 실패 시
*/
public function update(string $templateCode, array $data): array
{
$payload = array_merge($this->buildPayload($data), [
'templateCode' => $templateCode,
]);
$response = $this->kakao->request('/v3/kakao/template/update', $payload);
$this->assertSuccess($response);
$this->logActivity('sirsoft-message_bizppurio.alimtalk_template.update', [
'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_update',
'description_params' => ['template_code' => $templateCode],
]);
return (array) ($response['data'] ?? []);
}
/**
* 알림톡 템플릿을 삭제합니다. (대기(R) + 검수 REG/REJ 상태만 가능)
*
* @param string $templateCode 템플릿 코드
*
* @throws BizppurioApiException 삭제 실패 시
*/
public function delete(string $templateCode): void
{
$this->simpleAction('/v3/kakao/template/delete', $templateCode, 'delete');
}
/**
* 검수를 요청합니다. (대기(R) + 검수 REG 상태만 가능)
*
* @param string $templateCode 템플릿 코드
* @param string|null $comment 의견/문의(최대 500자)
*
* @throws BizppurioApiException 요청 실패 시
*/
public function requestInspection(string $templateCode, ?string $comment = null): void
{
$params = [
'senderKey' => $this->senderKey(),
'templateCode' => $templateCode,
];
if ($comment !== null && $comment !== '') {
$params['comment'] = $comment;
}
$response = $this->kakao->request('/v3/kakao/template/request', $params);
$this->assertSuccess($response);
$this->logActivity('sirsoft-message_bizppurio.alimtalk_template.request', [
'description_key' => 'sirsoft-message_bizppurio::activity_log.description.alimtalk_template_request',
'description_params' => ['template_code' => $templateCode],
]);
}
/**
* 검수 요청을 취소합니다. (검수 REQ 상태만 가능)
*
* @param string $templateCode 템플릿 코드
*
* @throws BizppurioApiException 취소 실패 시
*/
public function cancelRequest(string $templateCode): void
{
$this->simpleAction('/v3/kakao/template/cancel_request', $templateCode, 'cancel_request');
}
/**
* 승인된 템플릿을 중지합니다. (RDY/ACT 상태)
*
* @param string $templateCode 템플릿 코드
*
* @throws BizppurioApiException 중지 실패 시
*/
public function stop(string $templateCode): void
{
$this->simpleAction('/v3/kakao/template/stop', $templateCode, 'stop');
}
/**
* 중지된 템플릿을 정상으로 되돌립니다. (STP 상태)
*
* @param string $templateCode 템플릿 코드
*
* @throws BizppurioApiException 해제 실패 시
*/
public function reuse(string $templateCode): void
{
$this->simpleAction('/v3/kakao/template/reuse', $templateCode, 'reuse');
}
/**
* 승인을 취소합니다(재검수 요청 가능 상태로 복귀). (RDY/ACT 상태)
*
* @param string $templateCode 템플릿 코드
*
* @throws BizppurioApiException 취소 실패 시
*/
public function cancelApproval(string $templateCode): void
{
$this->simpleAction('/v3/kakao/template/cancel_approval', $templateCode, 'cancel_approval');
}
/**
* 휴면 템플릿을 해제합니다. (DMT 상태)
*
* @param string $templateCode 템플릿 코드
*
* @throws BizppurioApiException 해제 실패 시
*/
public function release(string $templateCode): void
{
$this->simpleAction('/v3/kakao/template/release', $templateCode, 'release');
}
/**
* senderKey + templateCode 만 필요한 단순 상태 변경 액션의 공통 처리.
*
* @param string $path kapi 엔드포인트 경로
* @param string $templateCode 템플릿 코드
* @param string $action 활동 로그 action 세그먼트
*
* @throws BizppurioApiException 요청 실패 시
*/
private function simpleAction(string $path, string $templateCode, string $action): void
{
$response = $this->kakao->request($path, [
'senderKey' => $this->senderKey(),
'templateCode' => $templateCode,
]);
$this->assertSuccess($response);
$this->logActivity("sirsoft-message_bizppurio.alimtalk_template.{$action}", [
'description_key' => "sirsoft-message_bizppurio::activity_log.description.alimtalk_template_{$action}",
'description_params' => ['template_code' => $templateCode],
]);
}
/**
* 화면 입력을 kapi 등록/수정 payload 로 조립합니다.
*
* - senderKey 는 환경설정에서 주입
* - templateMessageType 은 부가정보·채널추가(AC) 버튼 유무로 자동 계산
* - 빈 선택항목은 payload 에서 제외(kapi 유형별 필수 규칙 위반 방지)
*
* @param array<string, mixed> $data 검증된 입력
* @return array<string, mixed> kapi payload (bizId/apiKey 제외 — 클라이언트가 주입)
*/
private function buildPayload(array $data): array
{
$payload = [
'senderKey' => $this->senderKey(),
'senderKeyType' => 'S',
'templateName' => (string) $data['templateName'],
'templateContent' => (string) $data['templateContent'],
'categoryCode' => (string) $data['categoryCode'],
'templateEmphasizeType' => (string) ($data['templateEmphasizeType'] ?? 'NONE'),
];
if (! empty($data['templateCode'])) {
$payload['templateCode'] = (string) $data['templateCode'];
}
// 강조표기형(TEXT)
if ($payload['templateEmphasizeType'] === 'TEXT') {
$payload['templateTitle'] = (string) ($data['templateTitle'] ?? '');
$payload['templateSubtitle'] = (string) ($data['templateSubtitle'] ?? '');
}
// 이미지형(IMAGE)
if ($payload['templateEmphasizeType'] === 'IMAGE') {
$payload['templateImageName'] = (string) ($data['templateImageName'] ?? '');
$payload['templateImageUrl'] = (string) ($data['templateImageUrl'] ?? '');
}
// 선택항목(§6-4): 부가정보 · 미리보기 메시지
foreach (['templatePreviewMessage', 'templateExtra'] as $optional) {
if (! empty($data[$optional])) {
$payload[$optional] = (string) $data[$optional];
}
}
if (array_key_exists('securityFlag', $data)) {
$payload['securityFlag'] = (bool) $data['securityFlag'];
}
// 선택항목: 버튼(최대 5) · 바로연결(최대 10)
$buttons = $this->normalizeList($data['buttons'] ?? []);
if ($buttons !== []) {
$payload['buttons'] = $buttons;
}
$quickReplies = $this->normalizeList($data['quickReplies'] ?? []);
if ($quickReplies !== []) {
$payload['quickReplies'] = $quickReplies;
}
// 선택항목: 대표 링크(빈 링크 필드 제거 후 하나라도 있으면 포함)
$representLink = array_filter(
(array) ($data['templateRepresentLink'] ?? []),
static fn ($v) => is_string($v) && $v !== '',
);
if ($representLink !== []) {
$payload['templateRepresentLink'] = $representLink;
}
$payload['templateMessageType'] = $this->resolveMessageType($payload);
return $payload;
}
/**
* templateMessageType 을 부가정보·채널추가(AC) 버튼 유무로 계산합니다.
*
* - 부가정보(templateExtra) 있음 → EX
* - 채널추가(AC) 버튼 있음 → AD
* - 둘 다 → MI
* - 없음 → BA
*
* @param array<string, mixed> $payload 조립 중인 payload
* @return string BA/EX/AD/MI
*/
private function resolveMessageType(array $payload): string
{
$hasExtra = ! empty($payload['templateExtra']);
$hasAddChannel = false;
foreach ((array) ($payload['buttons'] ?? []) as $button) {
if (($button['linkType'] ?? null) === 'AC') {
$hasAddChannel = true;
break;
}
}
return match (true) {
$hasExtra && $hasAddChannel => 'MI',
$hasExtra => 'EX',
$hasAddChannel => 'AD',
default => 'BA',
};
}
/**
* 버튼/바로연결 배열에서 빈 항목을 제거하고 정규화합니다.
*
* @param mixed $list 입력 배열
* @return array<int, array<string, mixed>>
*/
private function normalizeList(mixed $list): array
{
if (! is_array($list)) {
return [];
}
return array_values(array_filter($list, static fn ($item) => is_array($item) && ! empty($item['name']) && ! empty($item['linkType'])));
}
/**
* 템플릿 행에 상태 배지·가능 액션을 부가합니다.
*
* serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다.
*
* @param array<string, mixed> $row kapi 템플릿 행
* @return array<string, mixed> 배지·액션이 부가된 행
*/
private function decorate(array $row): array
{
$status = (string) ($row['serviceStatus'] ?? $this->deriveStatus($row));
$badge = self::STATUS_BADGES[$status] ?? ['label_key' => 'unknown', 'variant' => 'gray'];
$row['service_status'] = $status;
$row['status_badge'] = [
'label_key' => 'sirsoft-message_bizppurio::messages.template.status.'.$badge['label_key'],
'variant' => $badge['variant'],
];
$row['available_actions'] = self::STATUS_ACTIONS[$status] ?? [];
return $row;
}
/**
* 상세 응답에서 serviceStatus 가 없을 때 status/inspectionStatus 로 상태를 추정합니다.
*
* 상세 조회는 serviceStatus 대신 status(S/A/R)+inspectionStatus(REG/REQ/REJ/APR)를
* 내려주므로, 목록과 동일한 배지 체계로 환원한다.
*
* @param array<string, mixed> $row kapi 템플릿 상세 행
* @return string serviceStatus 코드
*/
private function deriveStatus(array $row): string
{
$inspection = (string) ($row['inspectionStatus'] ?? '');
$status = (string) ($row['status'] ?? '');
$block = (bool) ($row['block'] ?? false);
$dormant = (bool) ($row['dormant'] ?? false);
return match (true) {
$block => 'BLK',
$dormant => 'DMT',
$inspection === 'REQ' => 'REQ',
$inspection === 'REJ' => 'REJ',
$inspection === 'APR' && $status === 'S' => 'STP',
$inspection === 'APR' && $status === 'A' => 'ACT',
$inspection === 'APR' => 'RDY',
default => 'REG',
};
}
/**
* 환경설정에서 발신프로필 키(sender_key)를 조회합니다.
*
* @return string 발신프로필 키
*
* @throws BizppurioApiException 미설정 시
*/
private function senderKey(): string
{
$settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? [];
$senderKey = (string) ($settings['sender_key'] ?? '');
if ($senderKey === '') {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.sender_key_missing'),
);
}
return $senderKey;
}
/**
* kapi 응답이 성공(200)이 아니면 message 를 담아 예외를 던집니다.
*
* @param array<string, mixed> $response kapi 응답
*
* @throws BizppurioApiException 실패 코드 시
*/
private function assertSuccess(array $response): void
{
if ($this->kakao->isSuccess($response)) {
return;
}
$message = (string) ($response['message'] ?? '');
$code = (string) ($response['code'] ?? '');
throw new BizppurioApiException(
$message !== ''
? $message
: __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
resultCode: $code !== '' ? $code : null,
);
}
}
@@ -87,6 +87,48 @@ class BizppurioKakaoApiClient
]);
}
/**
* 알림톡 이미지형 템플릿용 이미지를 업로드합니다. (`/v3/kakao/image/alimtalk/template`)
*
* multipart/form-data 로 이미지 파일을 전송하고, 성공 시 카카오 서버에 등록된 이미지
* URL(`image`)을 반환받는다. 이미지 제한: jpg/png, 최대 500KB, 가로 500px 이상.
*
* @param string $filePath 업로드할 이미지 파일의 절대 경로
* @param string $fileName 원본 파일명
* @return array<string, mixed> 응답 배열 (code/message/image)
*
* @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
*/
public function uploadTemplateImage(string $filePath, string $fileName): array
{
[$bizId, $apiKey] = $this->credentials();
$response = $this->http()
->attach('image', file_get_contents($filePath), $fileName)
->post(self::BASE_URL.'/v3/kakao/image/alimtalk/template', [
'bizId' => $bizId,
'apiKey' => $apiKey,
]);
if ($response->failed()) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
httpStatus: $response->status(),
);
}
$result = $response->json();
if (! is_array($result)) {
throw new BizppurioApiException(
__('sirsoft-message_bizppurio::messages.error.invalid_response'),
httpStatus: $response->status(),
);
}
return $result;
}
/**
* 카카오 관리 API 의 임의 엔드포인트를 호출합니다.
*
@@ -3,6 +3,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\BizppurioWebhookController;
use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist;
@@ -45,4 +46,40 @@ Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->g
]);
})->middleware('permission:admin,core.plugins.read')
->name('report.url');
/*
|----------------------------------------------------------------------
| 알림톡 템플릿 관리 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임
|----------------------------------------------------------------------
|
| 조회(list/detail/categories/profiles) = messaging.view
| 변경(store/update/destroy/검수/상태변경) = messaging.manage
| 템플릿은 DB 저장 없이 매 요청 실시간 조회. 설정 페이지 알림톡 템플릿 탭이 소비.
*/
Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () {
// 조회 (view)
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');
Route::get('/profiles', [AlimtalkTemplateController::class, 'profiles'])->name('profiles');
Route::get('/{templateCode}', [AlimtalkTemplateController::class, 'show'])->name('show');
});
// 변경 (manage)
Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () {
Route::post('/', [AlimtalkTemplateController::class, 'store'])->name('store');
// 이미지형 템플릿 이미지 업로드 (정적 경로 — {templateCode} 보다 먼저 선언)
Route::post('/image', [AlimtalkTemplateController::class, 'uploadImage'])->name('image');
Route::put('/{templateCode}', [AlimtalkTemplateController::class, 'update'])->name('update');
Route::delete('/{templateCode}', [AlimtalkTemplateController::class, 'destroy'])->name('destroy');
// 검수·상태 변경
Route::post('/{templateCode}/request', [AlimtalkTemplateController::class, 'requestInspection'])->name('request');
Route::post('/{templateCode}/cancel-request', [AlimtalkTemplateController::class, 'cancelRequest'])->name('cancel-request');
Route::post('/{templateCode}/stop', [AlimtalkTemplateController::class, 'stop'])->name('stop');
Route::post('/{templateCode}/reuse', [AlimtalkTemplateController::class, 'reuse'])->name('reuse');
Route::post('/{templateCode}/cancel-approval', [AlimtalkTemplateController::class, 'cancelApproval'])->name('cancel-approval');
Route::post('/{templateCode}/release', [AlimtalkTemplateController::class, 'release'])->name('release');
});
});
});
@@ -0,0 +1,301 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\AlimtalkTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Mockery;
use Mockery\MockInterface;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 알림톡 템플릿 관리 컨트롤러 Feature 테스트.
*
* 라우트 → 컨트롤러 → 서비스 경계를 검증한다. kapi 실제 호출 로직은
* AlimtalkTemplateServiceTest 가 검증하므로, 여기서는 AlimtalkTemplateService 를
* 컨테이너에 mock 으로 바인딩해 컨트롤러의 권한 경계(view/manage)·유형별 검증(FormRequest)·
* 응답 봉투·kapi 실패(예외)의 422 전파를 격리 검증한다.
*
* @since 1.0.0
*/
class AlimtalkTemplateControllerTest extends PluginTestCase
{
private const BASE = '/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates';
/**
* AlimtalkTemplateService mock 을 컨테이너에 바인딩하고 반환한다.
*/
private function mockService(): MockInterface
{
$mock = Mockery::mock(AlimtalkTemplateService::class);
$this->app->instance(AlimtalkTemplateService::class, $mock);
return $mock;
}
/**
* 지정 권한을 가진 admin 사용자를 생성한다.
*
* @param array<int, string> $permissionIdentifiers
*/
private function adminWith(array $permissionIdentifiers): User
{
$user = User::factory()->create();
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
);
$testRole = Role::create([
'identifier' => 'bizppurio_tpl_test_'.uniqid(),
'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
'type' => 'admin',
]);
$permissionIds = [];
foreach ($permissionIdentifiers as $identifier) {
$permissionIds[] = Permission::firstOrCreate(
['identifier' => $identifier],
['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
)->id;
}
$testRole->permissions()->sync($permissionIds);
$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();
}
/**
* @param array<int, string> $permissions
* @return array<string, string>
*/
private function authHeaders(array $permissions): array
{
$user = $this->adminWith($permissions);
$token = $user->createToken('test-token')->plainTextToken;
return [
'Authorization' => 'Bearer '.$token,
'Accept' => 'application/json',
];
}
/**
* @scenario auth=view,service=ok
*
* @effects list_returns_templates_with_status_badge
*/
public function test_view권한으로_목록을_조회한다(): void
{
$this->mockService()->shouldReceive('list')->once()->andReturn([
'templates' => [
['templateCode' => 'TW_1', 'templateName' => '주문완료', 'status_badge' => ['variant' => 'green']],
],
'pagination' => ['total' => 1, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20],
]);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->getJson(self::BASE);
$response->assertStatus(200);
$response->assertJsonPath('data.templates.0.templateCode', 'TW_1');
$response->assertJsonPath('data.templates.0.status_badge.variant', 'green');
}
/**
* @scenario auth=guest
*
* @effects list_requires_authentication_returns_401
*/
public function test_비인증은_401(): void
{
$this->getJson(self::BASE)->assertStatus(401);
}
/**
* @scenario auth=view_only,action=store
*
* @effects store_requires_manage_permission_returns_403
*/
public function test_view권한만으로_등록시_403(): void
{
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
->postJson(self::BASE, [
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
]);
$response->assertStatus(403);
}
/**
* @scenario auth=manage,service=ok,type=NONE
*
* @effects store_creates_template_via_service
*/
public function test_manage권한으로_기본형_등록에_성공한다(): void
{
$this->mockService()->shouldReceive('create')->once()->andReturn(['templateCode' => 'NEW']);
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE, [
'templateName' => '가입환영',
'templateContent' => '#{name}님 환영합니다',
'categoryCode' => '001001',
'templateEmphasizeType' => 'NONE',
]);
$response->assertStatus(200);
$response->assertJsonPath('data.template.templateCode', 'NEW');
}
/**
* @scenario auth=manage,service=ok,requestInspection=true
*
* @effects store_with_request_inspection_flag_registers_then_requests_inspection
*/
public function test_등록후검수요청_플래그로_등록과_검수요청을_이어서_수행한다(): void
{
$service = $this->mockService();
$service->shouldReceive('create')->once()->andReturn(['templateCode' => 'TW_NEW']);
$service->shouldReceive('requestInspection')->once()->with('TW_NEW');
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE, [
'templateName' => '가입환영',
'templateContent' => '#{name}님 환영합니다',
'categoryCode' => '001001',
'templateEmphasizeType' => 'NONE',
'requestInspection' => true,
]);
$response->assertStatus(200);
$response->assertJsonPath('data.template.templateCode', 'TW_NEW');
}
/**
* @scenario auth=manage,type=TEXT,missing=title
*
* @effects store_validates_text_type_requires_title_returns_422
*/
public function test_강조표기형에_강조문구_누락시_422(): void
{
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE, [
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'TEXT',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['templateTitle', 'templateSubtitle']);
}
/**
* @scenario auth=manage,type=IMAGE,missing=image
*
* @effects store_validates_image_type_requires_image_fields_returns_422
*/
public function test_이미지형에_이미지필드_누락시_422(): void
{
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE, [
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'IMAGE',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['templateImageName', 'templateImageUrl']);
}
/**
* @scenario auth=manage,service=throws
*
* @effects kapi_failure_is_surfaced_as_422_with_result_code
*/
public function test_kapi_실패는_422로_전파된다(): void
{
$this->mockService()->shouldReceive('create')->once()
->andThrow(new BizppurioApiException('템플릿 불일치', resultCode: '7204'));
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE, [
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
]);
$response->assertStatus(422);
$response->assertJsonPath('errors.result_code', '7204');
$response->assertJsonPath('errors.kakao_message', '템플릿 불일치');
}
/**
* @scenario auth=manage,action=stop
*
* @effects stop_action_delegates_to_service_stop
*/
public function test_중지_상태변경에_성공한다(): void
{
$this->mockService()->shouldReceive('stop')->once()->with('TW_1');
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->postJson(self::BASE.'/TW_1/stop');
$response->assertStatus(200);
}
/**
* @scenario auth=manage,action=upload_image
*
* @effects image_upload_returns_kakao_url
*/
public function test_이미지_업로드가_카카오_ur_l을_반환한다(): void
{
$this->mockService()->shouldReceive('uploadImage')->once()
->andReturn('https://mud-kage.kakao.com/dn/abc/banner.jpg');
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->post(self::BASE.'/image', [
'image' => UploadedFile::fake()->image('banner.jpg', 600, 400),
], ['Accept' => 'application/json']);
$response->assertStatus(200);
$response->assertJsonPath('data.image_url', 'https://mud-kage.kakao.com/dn/abc/banner.jpg');
}
/**
* @scenario auth=manage,action=upload_image,invalid=type
*
* @effects image_upload_rejects_non_image_returns_422
*/
public function test_이미지가_아닌_파일_업로드는_422다(): void
{
$response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
->post(self::BASE.'/image', [
'image' => UploadedFile::fake()->create('doc.pdf', 100, 'application/pdf'),
], ['Accept' => 'application/json']);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['image']);
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
@@ -12,8 +12,8 @@ use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 비즈뿌리오 메시징 플러그인 설치 스모크 테스트.
*
* plugin.php 가 선언하는 권한/메뉴/알림 정의/설정 스키마 구조와
* activate/deactivate 의 메뉴 동기화·정리 동작을 검증한다.
* plugin.php 가 선언하는 권한/알림 정의/설정 스키마 구조와
* 관리자 메뉴 미생성(⚑ 결정 3) 을 검증한다.
*/
class InstallationTest extends PluginTestCase
{
@@ -109,35 +109,28 @@ class InstallationTest extends PluginTestCase
}
}
public function test_activate_시_관리자_메뉴가_계층으로_생성된다(): void
/**
* 관리자 메뉴를 추가하지 않는다 (화면 배치 결정 2026-07-14, ⚑ 결정 3).
*
* 플러그인 메뉴 추가는 정식 기능이 아니므로 getAdminMenus()·메뉴 sync 를
* 폐기했다. 진입은 코어 소유 설정 페이지(/admin/plugins/{id}/settings) 하나로
* 통일한다. 활성화해도 플러그인 소속 메뉴 row 가 생성되지 않아야 한다.
*/
public function test_activate_시_관리자_메뉴를_생성하지_않는다(): void
{
$this->plugin->activate();
$parent = Menu::where('slug', 'sirsoft-message_bizppurio')
->where('extension_type', ExtensionOwnerType::Plugin->value)
->first();
$this->assertNotNull($parent, '최상위 메뉴가 생성되어야 한다.');
$children = Menu::where('parent_id', $parent->id)->get();
$this->assertCount(2, $children, '알림톡 템플릿 관리·발송 이력 2개 하위 메뉴가 생성되어야 한다.');
$childSlugs = $children->pluck('slug')->all();
$this->assertContains('sirsoft-message_bizppurio-alimtalk-templates', $childSlugs);
$this->assertContains('sirsoft-message_bizppurio-dispatches', $childSlugs);
}
public function test_deactivate_시_메뉴가_제거된다(): void
{
$this->plugin->activate();
$this->assertGreaterThan(0, Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count());
$this->plugin->deactivate();
$this->assertSame(
0,
Menu::where('extension_identifier', 'sirsoft-message_bizppurio')->count(),
'비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.'
'메뉴 폐기 결정에 따라 플러그인 소속 메뉴가 생성되면 안 된다.'
);
$this->assertNull(
Menu::where('slug', 'sirsoft-message_bizppurio')
->where('extension_type', ExtensionOwnerType::Plugin->value)
->first(),
'최상위 메시지 발송 관리 메뉴가 남아 있으면 안 된다.'
);
}
@@ -0,0 +1,274 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Services;
use App\Services\PluginSettingsService;
use Illuminate\Support\Facades\Http;
use Mockery;
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioKakaoApiClient;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* AlimtalkTemplateService — kapi 위임 + 상태 배지/액션 매핑 + messageType 자동계산 검증.
*/
class AlimtalkTemplateServiceTest extends PluginTestCase
{
private const IDENTIFIER = 'sirsoft-message_bizppurio';
/**
* @param array<string, string> $settings
*/
private function service(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): AlimtalkTemplateService
{
$pluginSettings = Mockery::mock(PluginSettingsService::class);
$pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings);
$kakao = new BizppurioKakaoApiClient($pluginSettings);
return new AlimtalkTemplateService($kakao, $pluginSettings);
}
public function test_목록은_상태_배지와_가능액션을_부가한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'totalCount' => 1,
'totalPage' => 1,
'currentPage' => 1,
'data' => ['list' => [
['templateCode' => 'TW_1', 'templateName' => '주문완료', 'serviceStatus' => 'ACT'],
]],
], 200),
]);
$result = $this->service()->list(['status' => 'ACT']);
$this->assertCount(1, $result['templates']);
$tpl = $result['templates'][0];
$this->assertSame('ACT', $tpl['service_status']);
$this->assertSame('green', $tpl['status_badge']['variant']);
$this->assertEqualsCanonicalizing(['stop', 'cancel_approval'], $tpl['available_actions']);
$this->assertSame(1, $result['pagination']['total']);
}
public function test_목록은_status_keyword를_kapi에_전달한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['list' => []]], 200)]);
$this->service()->list(['status' => 'REQ', 'keyword' => '주문', 'page' => 2, 'count' => 10]);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/list')
&& $request['templateStatus'] === 'REQ'
&& $request['keyword'] === '주문'
&& $request['page'] === 2
&& $request['count'] === 10
&& $request['senderKey'] === 'SK_40';
});
}
public function test_상세는_status_inspection으로_배지를_추론한다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response([
'code' => '200',
'data' => [
'templateCode' => 'TW_2',
'inspectionStatus' => 'APR',
'status' => 'A',
'block' => false,
'dormant' => false,
],
], 200),
]);
$detail = $this->service()->detail('TW_2');
// inspection=APR + status=A → ACT(정상)
$this->assertSame('ACT', $detail['service_status']);
$this->assertSame('green', $detail['status_badge']['variant']);
}
public function test_등록은_부가정보시_message_type_e_x로_계산한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'NEW']], 200)]);
$this->service()->create([
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
'templateExtra' => '부가정보',
]);
Http::assertSent(function ($request) {
return str_contains($request->url(), '/v3/kakao/template/add')
&& $request['templateMessageType'] === 'EX'
&& $request['templateExtra'] === '부가정보'
&& $request['senderKey'] === 'SK_40';
});
}
public function test_등록은_채널추가버튼시_message_type_a_d로_계산한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]);
$this->service()->create([
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
'buttons' => [['name' => '채널추가', 'linkType' => 'AC']],
]);
Http::assertSent(fn ($request) => $request['templateMessageType'] === 'AD');
}
public function test_등록은_부가정보와_채널추가_둘다면_m_i로_계산한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]);
$this->service()->create([
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
'templateExtra' => '부가',
'buttons' => [['name' => '채널추가', 'linkType' => 'AC']],
]);
Http::assertSent(fn ($request) => $request['templateMessageType'] === 'MI');
}
public function test_등록은_옵션없으면_b_a로_계산한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]);
$this->service()->create([
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
]);
Http::assertSent(fn ($request) => $request['templateMessageType'] === 'BA');
}
public function test_강조표기형은_title_subtitle을_전송한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]);
$this->service()->create([
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'TEXT',
'templateTitle' => '강조',
'templateSubtitle' => '보조',
]);
Http::assertSent(fn ($request) => $request['templateEmphasizeType'] === 'TEXT'
&& $request['templateTitle'] === '강조'
&& $request['templateSubtitle'] === '보조');
}
public function test_버튼과_바로연결과_대표링크를_전송한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200)]);
$this->service()->create([
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
'buttons' => [['name' => '웹링크', 'linkType' => 'WL', 'linkMo' => 'https://m.example.com']],
'quickReplies' => [['name' => '바로가기', 'linkType' => 'WL']],
'templateRepresentLink' => ['linkPc' => 'https://example.com', 'linkMo' => ''],
]);
Http::assertSent(function ($request) {
return $request['buttons'][0]['linkType'] === 'WL'
&& $request['quickReplies'][0]['name'] === '바로가기'
// 빈 링크 필드(linkMo='')는 제거되고 linkPc 만 포함
&& $request['templateRepresentLink'] === ['linkPc' => 'https://example.com'];
});
}
public function test_이미지_업로드는_카카오_이미지_ur_l을_반환한다(): void
{
Http::fake([
'kapi.ppurio.com/v3/kakao/image/*' => Http::response([
'code' => '200',
'image' => 'https://mud-kage.kakao.com/dn/abc/image.jpg',
], 200),
]);
$path = tempnam(sys_get_temp_dir(), 'img');
file_put_contents($path, 'fake-image-bytes');
$url = $this->service()->uploadImage($path, 'banner.jpg');
@unlink($path);
$this->assertSame('https://mud-kage.kakao.com/dn/abc/image.jpg', $url);
Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/image/alimtalk/template'));
}
public function test_검수요청은_템플릿코드와_comment를_전송한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->service()->requestInspection('TW_1', '검토 부탁드립니다');
Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/request')
&& $request['templateCode'] === 'TW_1'
&& $request['comment'] === '검토 부탁드립니다');
}
public function test_중지_상태변경은_stop_엔드포인트를_호출한다(): void
{
Http::fake(['kapi.ppurio.com/*' => Http::response(['code' => '200'], 200)]);
$this->service()->stop('TW_1');
Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/stop')
&& $request['templateCode'] === 'TW_1');
}
public function test_발신프로필_키_미설정시_예외(): void
{
$this->expectException(BizppurioApiException::class);
$this->service(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => ''])
->list();
}
public function test_kapi_실패코드시_예외에_결과코드가_담긴다(): void
{
Http::fake([
'kapi.ppurio.com/*' => Http::response(['code' => '7204', 'message' => '템플릿 불일치'], 200),
]);
try {
$this->service()->create([
'templateName' => 'T',
'templateContent' => '본문',
'categoryCode' => '001',
'templateEmphasizeType' => 'NONE',
]);
$this->fail('예외가 발생해야 한다.');
} catch (BizppurioApiException $e) {
$this->assertSame('7204', $e->getResultCode());
$this->assertStringContainsString('템플릿 불일치', $e->getMessage());
}
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
}
@@ -89,6 +89,33 @@ class BizppurioKakaoApiClientTest extends PluginTestCase
&& $request['templateName'] === 'T');
}
public function test_이미지_업로드는_multipart로_전송하고_ur_l을_받는다(): void
{
Http::fake([
'kapi.ppurio.com/v3/kakao/image/*' => Http::response([
'code' => '200',
'image' => 'https://mud-kage.kakao.com/dn/xyz/img.jpg',
], 200),
]);
$path = tempnam(sys_get_temp_dir(), 'img');
file_put_contents($path, 'bytes');
$result = $this->client()->uploadTemplateImage($path, 'banner.jpg');
@unlink($path);
$this->assertSame('https://mud-kage.kakao.com/dn/xyz/img.jpg', $result['image']);
Http::assertSent(function ($request) {
// multipart 요청은 배열 접근($request['key'])이 불가하므로 URL·메서드·multipart 여부로 검증
$bodyData = collect($request->data())->keyBy('name');
return str_contains($request->url(), '/v3/kakao/image/alimtalk/template')
&& $request->isMultipart()
&& ($bodyData['bizId']['contents'] ?? null) === 'biz01';
});
}
public function test_자격증명_미설정시_예외(): void
{
$client = new BizppurioKakaoApiClient(
@@ -0,0 +1,104 @@
# audit:allow test-scenario-coverage reason: |
# 알림톡 템플릿 관리 화면은 설정 페이지(plugin_settings.json)의 탭으로 배치되며(⚑ 결정 2·3,
# 독립 페이지·메뉴 없음), kapi 실시간 위임이라 DB 저장이 없다. axes cross product 의 UI 구조
# (탭 전환·목록/폼 서브뷰·유형별 필드·변수삽입·상세 모달·i18n) effects 는 Vitest
# (alimtalk_templates.test.tsx) 정적 검증으로 가드된다. 권한 경계(view/manage)·유형별
# 조건부 검증(422)·kapi 실패 전파(422)·상태변경 위임은 PHPUnit Feature
# (AlimtalkTemplateControllerTest) 로, messageType 자동계산·상태 배지/액션 매핑·kapi 파라미터
# 전달은 Unit(AlimtalkTemplateServiceTest) 로 Http::fake 왕복 관찰한다. 런타임 Playwright spec 은
# 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 카카오 관리 API 자격증명 필요) 단계에서 최초
# 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다.
feature: 알림톡 템플릿 관리 (Phase 5)
description: |
설정 페이지 알림톡 템플릿 탭에서 카카오 관리 API(kapi.ppurio.com)로 알림톡 템플릿을
실시간 조회·등록·수정·삭제·검수·상태변경한다. DB 저장 없이 매 요청 kapi 위임이며, 저장은
하지 않는다(연동 binding 은 Phase 6). 진입은 코어 소유 설정 페이지 하나로 통일(메뉴 없음).
핵심 동작:
- 탭 네비게이션: 환경설정 ↔ 알림톡 템플릿 (activeSettingsTab, 배타 전환)
- 목록 서브뷰: 상태 필터(templateStatus) + 검색(keyword) + 상태 배지(serviceStatus) + 상세
- 폼 서브뷰(목록↔폼 전환): 유형별 필드(NONE/IMAGE/TEXT, ITEM_LIST 제외) + 변수 클릭삽입
- templateMessageType(BA/EX/AD/MI)은 부가정보·채널추가(AC) 버튼 유무로 백엔드 자동 계산
- 권한: 조회=messaging.view / 변경=messaging.manage
- kapi 실패(반려·차단·상태오류)는 그대로 422 로 전파(운영자가 사유 즉시 확인)
- readiness: api_key·sender_key 미설정 시 안내 + [설정하기] 탭 이동
axes:
auth: [view, manage, guest]
emphasize_type: [NONE, IMAGE, TEXT]
message_type_driver: [none, extra, add_channel, both]
save_mode: [register, register_and_request]
kapi_result: [ok, fail]
exclusions:
- { auth: guest, emphasize_type: IMAGE, reason: "비인증은 유형 무관 401 — 유형 axis 무의미" }
- { auth: view, kapi_result: fail, reason: "view 는 변경 액션 불가(403) — kapi 도달 전 차단" }
effects:
# 탭 네비게이션 (UI)
- tab_navigation_has_connection_and_templates_tabs
- templates_tab_updates_url_query_and_refetches_list
- tab_panels_toggle_exclusively_by_query_tab
- templates_tab_persists_across_reload_via_url_query
- init_actions_loads_list_when_entering_templates_tab
# 데이터소스 (UI)
- templates_and_categories_datasources_target_admin_api
# 목록 서브뷰 (UI)
- list_view_shown_when_template_view_is_list
- toolbar_has_status_filter_and_keyword_search
- table_card_always_shows_header_and_branches_tbody_by_count
- table_iterates_templates_with_status_badge
- new_template_button_switches_to_form_and_resets_form
# 폼 서브뷰 (UI, 계획서 §6-4)
- form_view_shown_when_template_view_is_form
- common_fields_name_and_content_use_manual_value_binding
- template_code_field_auto_or_manual_toggle
- category_two_level_dropdown_from_category_all
- text_type_fields_shown_when_emphasize_type_is_text
- image_type_file_upload_shown_when_emphasize_type_is_image
- type_buttons_expose_only_none_image_text_not_item_list
- variable_buttons_insert_into_content_state_via_insert_variable_handler
- variable_reference_table_always_visible_inline
- optional_six_items_checkbox_expand_extra_preview_button_quickreply_link_security
- optional_no_header_field
- preview_reflects_content_state
- save_split_into_register_and_register_then_request_inspection_with_help_box
# 상세 모달 & readiness (UI)
- detail_modal_defined
- readiness_notice_shown_when_api_key_or_sender_key_missing
# 권한 경계 (백엔드)
- list_requires_view_permission
- guest_list_returns_401
- store_requires_manage_permission_returns_403_for_view_only
- manage_can_create_basic_template
- register_then_request_inspection_flag_creates_then_requests
# 유형별 검증 (백엔드)
- text_type_missing_title_subtitle_returns_422
- image_type_missing_image_fields_returns_422
# messageType 자동계산 (백엔드)
- message_type_none_computes_ba
- message_type_extra_computes_ex
- message_type_add_channel_computes_ad
- message_type_both_computes_mi
# 상태 배지/액션 & kapi 전달 (백엔드)
- service_status_maps_to_badge_and_available_actions
- detail_derives_status_from_inspection_and_status
- list_passes_status_keyword_to_kapi
- text_type_sends_title_and_subtitle
- inspection_request_sends_code_and_comment
- stop_action_calls_kapi_stop_endpoint
# kapi 실패 전파 (백엔드)
- kapi_failure_surfaced_as_422_with_result_code
- sender_key_missing_throws
# 다국어
- all_referenced_i18n_keys_exist_in_ko_and_en
test_files:
- plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php
audit:
status: codified
@@ -24,7 +24,8 @@ description: |
- 리포트 수신 설정: 발송 결과 수신 주소를 사이트 도메인 기준 절대 URL 로 조회·복사
- 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false
- 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT
- 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드
- 설치·활성화 시 권한 2개(view/manage)·잔액부족 알림 정의 생성 + 설정 초기값 로드
(관리자 메뉴는 추가하지 않음 — ⚑ 결정 3, 진입은 코어 소유 설정 페이지 하나)
※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관.
@@ -65,8 +66,7 @@ effects:
- all_referenced_i18n_keys_exist_in_ko_and_en
# 설치·활성화 (백엔드)
- install_creates_view_and_manage_permissions
- activate_creates_hierarchical_admin_menus
- deactivate_removes_plugin_menus
- activate_does_not_create_admin_menus # ⚑ 결정 3: 플러그인 메뉴 폐기, 진입은 설정 페이지 하나로 통일
- balance_low_notification_targets_admin
- settings_defaults_load_with_test_mode_on
@@ -0,0 +1,41 @@
import { defineConfig } from 'vite';
import path from 'path';
/**
* 비즈뿌리오 메시징 플러그인 프론트엔드 에셋 빌드 설정.
*
* 커스텀 핸들러(insertVariable) 를 IIFE 번들로 빌드해 플러그인 활성화 시 로드한다.
* 출력물은 dist/js/plugin.iife.js 이며 plugin.json assets.js.output 과 일치한다.
*/
export default defineConfig({
define: {
'process.env.NODE_ENV': JSON.stringify('production'),
},
build: {
lib: {
entry: path.resolve(__dirname, 'resources/js/index.ts'),
name: 'SirsoftMessageBizppurio',
fileName: 'plugin',
formats: ['iife'],
},
outDir: 'dist',
emptyOutDir: true,
sourcemap: true,
rollupOptions: {
output: {
entryFileNames: 'js/plugin.iife.js',
chunkFileNames: 'js/[name]-[hash].js',
},
},
minify: 'esbuild',
target: 'es2020',
chunkSizeWarningLimit: 500,
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'resources/js'),
},
},
});