feat(message_bizppurio): 알림톡 템플릿 관리 화면 (설정 페이지 탭)
설정 페이지 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 등록·수정·삭제·검수· 상태변경한다. 진입은 코어 소유 설정 페이지 하나로 통일하고 getAdminMenus 는 폐기했다. 템플릿은 kapi 에 실시간 위임하며 DB 에 저장하지 않는다. 등록 폼은 계획서 §6-4 전체를 구현한다: 템플릿 코드(자동 생성/직접 입력), 카테고리 2단 드롭다운, 템플릿 유형(기본형/이미지형/강조표기형), 이미지형 파일 업로드, 본문 변수 클릭 삽입 + 알림별 변수 참고표, 선택 항목 6개 체크펼침(부가정보·미리보기·버튼·바로연결·대표링크·보안), 등록/등록 후 검수요청 2버튼.
This commit is contained in:
1 parent
77df8c9a7c
commit
73b05aa42f
37 files changed
+4902
-111
No files matched your search
@@ -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 제거 성공 여부
|
||||
*/
|
||||
|
||||
+236
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
+9
-3
@@ -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);
|
||||
}
|
||||
+98
@@ -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": "새 댓글"
|
||||
}
|
||||
}
|
||||
}
|
||||
+1217
-5
File diff suppressed because it is too large.
Load diff
+284
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
+16
@@ -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 {}
|
||||
+51
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+301
@@ -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();
|
||||
}
|
||||
}
|
||||
+18
-25
@@ -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(),
|
||||
'최상위 메시지 발송 관리 메뉴가 남아 있으면 안 된다.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+274
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user