feat(message_bizppurio): 비즈뿌리오 메시징 플러그인 골격 + 환경설정

Phase 1 — 플러그인 설치·활성화 시 권한·메뉴·환경설정 화면이 뜨는 기반 구축.
이후 발송엔진·채널·이력·템플릿 Phase 의 전제가 된다.

- 관리자 환경설정 화면(3섹션: API 연동/발송 설정/연동 안내) — 비즈뿌리오
 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영).
 크리덴셜은 마스킹 + 프론트 미노출.
- 조회/관리 권한 2개 + 「메시지 발송 관리」 관리자 메뉴(알림톡 템플릿·발송 이력 하위).
- 잔액 부족 시 관리자 알림 정의(webhook 결과코드 감지는 Phase 4).
- 발송 관련 Enum 4종(채널/상태/출처/결과분류) — Phase 2~4 소비용 골격.

알림톡 채널 기본 body 시드는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이라
Phase 6 으로 이관(plugin.php 주석 명시).

테스트: PHPUnit 12건(설치·권한·메뉴·알림 정의·Enum) + Vitest 22건(환경설정 레이아웃).
설치→활성화 실검증으로 권한 4·메뉴 3·알림 1·설정 초기값 로드 확인.
This commit is contained in:
chym1217
2026-07-28 12:36:18 +09:00
parent d03c731876
commit b48ac91fec
25 changed files with 2301 additions and 0 deletions
@@ -0,0 +1,13 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.0] - 2026-07-09
### Added
- 비즈뿌리오(다우기술) 연동 환경설정 화면을 추가했습니다. 비즈뿌리오 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영)을 관리자 화면에서 설정할 수 있습니다.
- 관리자 「메시지 발송 관리」 메뉴와 조회·관리 권한을 추가했습니다.
- 비즈뿌리오 지갑 잔액이 부족할 때 관리자에게 알리는 알림을 추가했습니다.
@@ -0,0 +1,47 @@
프로그램 명칭 : 그누보드7용 비즈뿌리오 메시징 플러그인 (sirsoft-message_bizppurio)
저작자 : (주)에스아이알소프트
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,22 @@
{
"name": "plugins/sirsoft-message_bizppurio",
"description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft",
"type": "library",
"version": "1.0.0",
"license": "MIT",
"authors": [
{
"name": "sirsoft",
"email": "contact@sirsoft.com"
}
],
"require": {
"php": "^8.2",
"ext-json": "*"
},
"autoload": {
"psr-4": {
"Plugins\\Sirsoft\\MessageBizppurio\\": ["src/", "./"]
}
}
}
@@ -0,0 +1,22 @@
{
"_meta": {
"version": "1.0.0",
"description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false."
},
"defaults": {
"environment": "dev",
"bizppurio_id": "",
"password": "",
"api_key": "",
"sender_number": "",
"sender_key": ""
},
"frontend_schema": {
"environment": { "expose": false },
"bizppurio_id": { "expose": false },
"password": { "expose": false, "sensitive": true },
"api_key": { "expose": false, "sensitive": true },
"sender_number": { "expose": false },
"sender_key": { "expose": false, "sensitive": true }
}
}
@@ -0,0 +1,33 @@
<?php
return [
// 발송 채널 (DispatchChannel enum)
'channel' => [
'sms' => 'SMS',
'lms' => 'LMS',
'alimtalk' => 'Alimtalk',
],
// 발송 상태 (DispatchStatus enum)
'status' => [
'pending' => 'Pending',
'sent' => 'Sending',
'success' => 'Success',
'failed' => 'Failed',
],
// 발송 출처 (DispatchSource enum)
'source' => [
'auto' => 'Automatic',
'manual' => 'Manual',
'bulk' => 'Bulk',
],
// 결과코드 분류 (ResultCategory enum)
'result_category' => [
'success' => 'Success',
'retry' => 'Retry',
'permanent_failure' => 'Permanent Failure',
'balance_low' => 'Insufficient Balance',
],
];
@@ -0,0 +1,33 @@
<?php
return [
// 발송 채널 (DispatchChannel enum)
'channel' => [
'sms' => 'SMS',
'lms' => 'LMS',
'alimtalk' => '알림톡',
],
// 발송 상태 (DispatchStatus enum)
'status' => [
'pending' => '대기',
'sent' => '발송중',
'success' => '성공',
'failed' => '실패',
],
// 발송 출처 (DispatchSource enum)
'source' => [
'auto' => '자동',
'manual' => '수동',
'bulk' => '대량',
],
// 결과코드 분류 (ResultCategory enum)
'result_category' => [
'success' => '성공',
'retry' => '재시도',
'permanent_failure' => '영구 실패',
'balance_low' => '잔액 부족',
],
];
@@ -0,0 +1,16 @@
{
"name": "@plugins/sirsoft-message_bizppurio",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "vitest",
"test:run": "vitest run"
},
"devDependencies": {
"@types/node": "^22.0.0",
"jsdom": "^25.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
@@ -0,0 +1,25 @@
{
"identifier": "sirsoft-message_bizppurio",
"vendor": "sirsoft",
"name": {
"ko": "비즈뿌리오 메시징",
"en": "Bizppurio Messaging"
},
"version": "1.0.0",
"description": {
"ko": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.",
"en": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook."
},
"license": "MIT",
"g7_version": ">=7.0.3",
"dependencies": {
"modules": {},
"plugins": {}
},
"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",
"loading": {
"strategy": "global",
"priority": 100
}
}
@@ -0,0 +1,367 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio;
use App\Enums\ExtensionOwnerType;
use App\Extension\AbstractPlugin;
use App\Extension\Helpers\ExtensionMenuSyncHelper;
/**
* 비즈뿌리오 메시징 플러그인
*
* 비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송을 제공합니다.
* 코어 알림 시스템의 채널로 문자·알림톡을 발송하고, 발송 결과를 webhook 으로
* 수신하여 이력에 기록합니다.
*/
class Plugin extends AbstractPlugin
{
/**
* 플러그인 메타데이터 반환
*
* @return array 메타데이터
*/
public function getMetadata(): array
{
return [
'author' => 'Sirsoft',
'license' => 'MIT',
'homepage' => 'https://sir.kr',
'keywords' => ['bizppurio', 'sms', 'lms', 'alimtalk', 'kakao', 'messaging', 'notification'],
];
}
/**
* 관리자 메뉴 정의
*
* 코어 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 처리.
*
* @return bool 비활성화 성공 여부
*/
public function deactivate(): bool
{
app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus(
ExtensionOwnerType::Plugin,
$this->getIdentifier(),
currentSlugs: [],
);
return true;
}
/**
* 플러그인 제거 — 메뉴 잔존 안전망 (정상 흐름은 deactivate 가 먼저 처리).
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool
{
$this->deactivate();
return true;
}
/**
* 플러그인 권한 목록 반환 (계층 구조)
*
* PluginManager 가 1레벨(플러그인 노드) → 2레벨(카테고리) → 3레벨(개별 권한) 트리로 등록.
* 모든 권한은 admin 역할에 매핑.
*
* 권한 분할 의도:
* - view: 발송 이력·알림톡 템플릿 조회 (모니터링)
* - manage: 환경설정·템플릿 등록/검수·이벤트 연동 (운영)
*
* @return array 권한 정의 배열 (categories 계층 구조)
*/
public function getPermissions(): array
{
return [
'name' => [
'ko' => '비즈뿌리오 메시징',
'en' => 'Bizppurio Messaging',
],
'description' => [
'ko' => '비즈뿌리오 메시징 플러그인이 제공하는 권한',
'en' => 'Permissions provided by the Bizppurio Messaging plugin',
],
'categories' => [
[
'identifier' => 'messaging',
'name' => ['ko' => '메시지 발송', 'en' => 'Messaging'],
'description' => [
'ko' => '메시지 발송 도메인 권한 (조회·관리)',
'en' => 'Messaging domain permissions (view, manage)',
],
'permissions' => [
[
'action' => 'view',
'name' => ['ko' => '메시지 조회', 'en' => 'View Messaging'],
'description' => [
'ko' => '발송 이력·알림톡 템플릿 조회 (모니터링)',
'en' => 'View dispatch history and alimtalk templates (monitoring)',
],
'type' => 'admin',
'roles' => ['admin'],
],
[
'action' => 'manage',
'name' => ['ko' => '메시지 관리', 'en' => 'Manage Messaging'],
'description' => [
'ko' => '환경설정·알림톡 템플릿 등록/검수·이벤트 연동 관리',
'en' => 'Manage settings, alimtalk template registration/inspection, and event bindings',
],
'type' => 'admin',
'roles' => ['admin'],
],
],
],
],
];
}
/**
* 플러그인 설정 스키마 반환
*
* 관리자 설정 페이지 UI 를 동적으로 생성하는 데 사용됩니다. 크리덴셜(비밀번호·API 키)은
* sensitive 로 마킹하여 마스킹하며, frontend_schema(defaults.json)에서 expose:false 로
* 프론트 노출을 차단합니다.
*
* 발송 시스템(account)과 카카오 관리 시스템(bizId)의 식별자는 동일한 '비즈뿌리오 아이디'
* 이므로 bizppurio_id 단일 필드로 받는다.
*
* @return array 설정 스키마
*/
public function getSettingsSchema(): array
{
return [
'environment' => [
'type' => 'enum',
'options' => ['dev', 'live'],
'default' => 'dev',
'label' => ['ko' => '연동 환경', 'en' => 'Environment'],
'hint' => [
'ko' => '검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.',
'en' => 'Select the inspection (dev) or production environment. The sending API domain differs by environment.',
],
'required' => false,
],
'bizppurio_id' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '비즈뿌리오 아이디', 'en' => 'Bizppurio ID'],
'hint' => [
'ko' => '발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다.',
'en' => 'The Bizppurio account ID used for both sending and Kakao management.',
],
'required' => false,
],
'password' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '비밀번호', 'en' => 'Password'],
'hint' => [
'ko' => '발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다.',
'en' => 'The Bizppurio password used to issue the sending token.',
],
'sensitive' => true,
'required' => false,
],
'api_key' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => 'API 키', 'en' => 'API Key'],
'hint' => [
'ko' => '카카오 관리(알림톡 템플릿·발신프로필)에 사용하는 API 키입니다. 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다.',
'en' => 'The API key used for Kakao management (alimtalk templates, sender profiles).',
],
'sensitive' => true,
'required' => false,
],
'sender_number' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '발신번호', 'en' => 'Sender Number'],
'hint' => [
'ko' => '문자·알림톡 발송에 사용하는 발신 전화번호입니다.',
'en' => 'The sender phone number used for SMS and alimtalk delivery.',
],
'required' => false,
],
'sender_key' => [
'type' => 'string',
'default' => '',
'label' => ['ko' => '알림톡 발신프로필 키', 'en' => 'Alimtalk Sender Profile Key'],
'hint' => [
'ko' => '알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다.',
'en' => 'The 40-character sender profile key used for alimtalk delivery and template lookup.',
],
'sensitive' => true,
'required' => false,
],
];
}
/**
* 플러그인 설정 기본값 반환 (하위 호환)
*
* 신규 설치 기본값은 config/settings/defaults.json 의 defaults 섹션이 1순위이며,
* 본 메서드는 하위 호환 경로로 동일 값을 반환한다.
*
* @return array 기본 설정값
*/
public function getConfigValues(): array
{
return [
'environment' => 'dev',
'bizppurio_id' => '',
'password' => '',
'api_key' => '',
'sender_number' => '',
'sender_key' => '',
];
}
/**
* 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다.
*
* Phase 1 범위: 비즈뿌리오 지갑 잔액부족 시 관리자에게 보내는 자체 알림 1건.
* (webhook 결과코드 9070/7436 감지 시 Phase 4 에서 이 알림을 발화)
*
* ※ 3영역(코어/게시판/이커머스) 알림톡 채널 기본 body 시드는 알림톡 채널 등록(Phase 3)·
* 탭 연동(Phase 6)과 강결합이므로 Phase 6 으로 이관한다. 그때 본 메서드에 정의를 추가한다.
*
* @return array<int, array<string, mixed>>
*/
public function getNotificationDefinitions(): array
{
return [
[
'type' => 'bizppurio_balance_low',
'hook_prefix' => 'sirsoft-message_bizppurio',
'name' => [
'ko' => '비즈뿌리오 잔액 부족',
'en' => 'Bizppurio Balance Low',
],
'description' => [
'ko' => '비즈뿌리오 지갑 잔액이 부족해 문자/알림톡 발송이 실패했을 때 관리자에게 발송',
'en' => 'Sent to admin when a message fails due to insufficient Bizppurio wallet balance',
],
'channels' => ['mail', 'database'],
'hooks' => ['sirsoft-message_bizppurio.balance.low'],
'variables' => [
['key' => 'name', 'description' => '수신자(관리자) 이름'],
['key' => 'app_name', 'description' => '사이트 이름'],
['key' => 'result_code', 'description' => '결과 코드(9070 문자 / 7436 알림톡)'],
['key' => 'channel_label', 'description' => '발송 채널(문자/알림톡)'],
['key' => 'settings_url', 'description' => '메시징 환경설정 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'role', 'value' => 'admin']],
'subject' => [
'ko' => '[{app_name}] 비즈뿌리오 잔액이 부족합니다',
'en' => '[{app_name}] Bizppurio balance is insufficient',
],
'body' => [
'ko' => '{name}님, 비즈뿌리오 지갑 잔액이 부족하여 {channel_label} 발송이 실패했습니다 (코드: {result_code}). 충전 후 발송이 정상화됩니다.',
'en' => 'Dear {name}, a {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}). Delivery resumes after recharging.',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'role', 'value' => 'admin']],
'subject' => [
'ko' => '비즈뿌리오 잔액 부족',
'en' => 'Bizppurio balance low',
],
'body' => [
'ko' => '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).',
'en' => 'A {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}).',
],
],
],
],
];
}
/**
* 훅 리스너 목록 반환
*
* Phase 3~6 에서 채널 등록/발송 위임/webhook 리스너를 추가한다.
*
* @return array<class-string>
*/
public function getHookListeners(): array
{
return [];
}
}
@@ -0,0 +1,120 @@
/**
* 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼
*
* JSON 트리에서 ID/이름 검색 및 핸들러·i18n 키 추출을 위한 유틸리티.
*/
export type AnyNode = Record<string, unknown> & {
id?: string;
name?: string;
type?: string;
children?: AnyNode[];
slots?: Record<string, AnyNode[]>;
modals?: Record<string, AnyNode> | AnyNode[];
actions?: AnyNode[];
iteration?: { source?: string; item_var?: string; index_var?: string };
if?: string;
props?: Record<string, unknown>;
text?: string;
};
/**
* 트리 노드에서 특정 id를 재귀 탐색 (children/slots/modals)
*/
export function findById(node: AnyNode | undefined | null, id: string): AnyNode | null {
if (!node) return null;
if (node.id === id) return node;
if (Array.isArray(node.children)) {
for (const child of node.children) {
const found = findById(child, id);
if (found) return found;
}
}
if (node.slots && typeof node.slots === 'object') {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
const found = findById(child, id);
if (found) return found;
}
}
}
}
if (node.modals) {
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
for (const m of modalEntries) {
const found = findById(m as AnyNode, id);
if (found) return found;
}
}
return null;
}
/**
* 트리 노드에서 특정 name 컴포넌트를 모두 수집 (children/slots/modals)
*/
export function findAllByName(node: AnyNode | undefined | null, name: string): AnyNode[] {
const results: AnyNode[] = [];
if (!node) return results;
if (node.name === name) results.push(node);
if (Array.isArray(node.children)) {
for (const child of node.children) {
results.push(...findAllByName(child, name));
}
}
if (node.slots && typeof node.slots === 'object') {
for (const slotChildren of Object.values(node.slots)) {
if (Array.isArray(slotChildren)) {
for (const child of slotChildren) {
results.push(...findAllByName(child, name));
}
}
}
}
if (node.modals) {
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
for (const m of modalEntries) {
results.push(...findAllByName(m as AnyNode, name));
}
}
return results;
}
/**
* 특정 name 컴포넌트 중 props.name 속성이 주어진 값인 첫 노드를 찾음 (폼 입력 필드 탐색용)
*/
export function findInputByName(node: AnyNode | undefined | null, inputName: string): AnyNode | null {
for (const candidate of [...findAllByName(node, 'Input'), ...findAllByName(node, 'Select')]) {
if ((candidate.props as { name?: string } | undefined)?.name === inputName) {
return candidate;
}
}
return null;
}
/**
* JSON 문자열에서 사용된 핸들러 이름 모두 수집
*/
export function collectHandlers(json: unknown): string[] {
const text = JSON.stringify(json);
const matches = text.match(/"handler":\s*"([^"]+)"/g) ?? [];
const names = matches
.map((m) => m.match(/"handler":\s*"([^"]+)"/)?.[1] ?? '')
.filter(Boolean);
return Array.from(new Set(names));
}
/**
* JSON 문자열에서 사용된 i18n 키($t:sirsoft-message_bizppurio.*) 모두 수집
*/
export function collectI18nKeys(json: unknown): string[] {
const text = JSON.stringify(json);
const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
return Array.from(new Set(matches));
}
@@ -0,0 +1,154 @@
/**
* 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§6-1)
*
* 3 섹션:
* 1. section_api (연동 환경 + 비즈뿌리오 아이디 + 비밀번호 + API 키)
* 2. section_sending (발신번호 + 알림톡 발신프로필 키)
* 3. section_integration (webhook 수신 주소 안내)
*
* 크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹, 저장은
* 자동바인딩(_local.form) → 코어 /api/admin/plugins/{id}/settings PUT.
*/
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,
findInputByName,
collectHandlers,
collectI18nKeys,
type AnyNode,
} from './helpers';
const root = layout as unknown as AnyNode;
describe('plugin_settings.json — 레이아웃 메타/권한', () => {
it('layout_name 이 plugin_settings 이다', () => {
expect((root as { layout_name?: string }).layout_name).toBe('plugin_settings');
});
it('_admin_base 를 상속한다', () => {
expect((root as { extends?: string }).extends).toBe('_admin_base');
});
it('core.plugins.update 권한을 요구한다', () => {
expect((root as { permissions?: string[] }).permissions).toContain('core.plugins.update');
});
it('settings 데이터소스가 코어 플러그인 설정 API 를 조회한다', () => {
const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
const settings = sources.find((s) => s.id === 'settings');
expect(settings).toBeTruthy();
expect(settings?.endpoint).toBe('/api/admin/plugins/{{route.identifier}}/settings');
expect(settings?.initLocal).toBe('form');
});
});
describe('plugin_settings.json — 자동바인딩', () => {
it('컨테이너가 dataKey=form + trackChanges 로 자동바인딩한다', () => {
const container = findById(root, 'plugin_settings_content');
expect(container).toBeTruthy();
expect((container as { dataKey?: string }).dataKey).toBe('form');
expect((container as { trackChanges?: boolean }).trackChanges).toBe(true);
});
});
describe('plugin_settings.json — 3 섹션', () => {
it.each([
['section_api', 'API 연동'],
['section_sending', '발송 설정'],
['section_integration', '연동 안내'],
])('%s 섹션이 존재한다', (id) => {
expect(findById(root, id)).toBeTruthy();
});
});
describe('plugin_settings.json — 입력 필드 6종', () => {
it.each([
'environment',
'bizppurio_id',
'password',
'api_key',
'sender_number',
'sender_key',
])('%s 입력 필드가 자동바인딩 name 으로 존재한다', (name) => {
expect(findInputByName(root, name)).toBeTruthy();
});
it('크리덴셜(password/api_key/sender_key)은 type=password 로 마스킹된다', () => {
for (const cred of ['password', 'api_key', 'sender_key']) {
const input = findInputByName(root, cred);
expect((input?.props as { type?: string } | undefined)?.type).toBe('password');
}
});
it('bizppurio_id/sender_number 는 일반 text 입력이다', () => {
for (const field of ['bizppurio_id', 'sender_number']) {
const input = findInputByName(root, field);
expect((input?.props as { type?: string } | undefined)?.type).toBe('text');
}
});
it('environment 는 dev/live 옵션 Select 이다', () => {
const select = findInputByName(root, 'environment');
const options = (select?.children ?? []).map(
(c) => (c.props as { value?: string } | undefined)?.value
);
expect(options).toEqual(['dev', 'live']);
});
});
describe('plugin_settings.json — webhook 안내', () => {
it('연동 안내 섹션에 고정 webhook 경로가 readonly 로 표시된다', () => {
const section = findById(root, 'section_integration');
const inputs = JSON.stringify(section);
expect(inputs).toContain('/api/plugins/sirsoft-message_bizppurio/webhook');
expect(inputs).toContain('"readOnly":true');
});
});
describe('plugin_settings.json — 저장 버튼', () => {
it('저장 버튼이 hasChanges 없으면 비활성화된다', () => {
const save = findById(root, 'save_button');
expect((save?.props as { disabled?: string } | undefined)?.disabled).toContain('!_local.hasChanges');
});
it('저장은 코어 설정 API 로 PUT 한다', () => {
const text = JSON.stringify(findById(root, 'save_button'));
expect(text).toContain('/api/admin/plugins/{{route.identifier}}/settings');
expect(text).toContain('"method":"PUT"');
expect(text).toContain('{{_local.form}}');
});
it('등록된 핸들러만 사용한다 (오탈자 핸들러 없음)', () => {
const handlers = collectHandlers(layout);
const allowed = ['apiCall', 'setState', 'toast', 'navigate', 'sequence', 'refetchDataSource', 'scrollIntoView'];
for (const h of handlers) {
expect(allowed).toContain(h);
}
});
});
describe('plugin_settings.json — i18n 키 정합', () => {
it('레이아웃이 참조하는 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => {
const keys = collectI18nKeys(layout);
expect(keys.length).toBeGreaterThan(0);
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) {
// "$t:sirsoft-message_bizppurio.settings.title" → "settings.title"
const path = raw.replace('$t:sirsoft-message_bizppurio.', '');
expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy();
expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy();
}
});
});
@@ -0,0 +1,60 @@
{
"name": "Bizppurio Messaging",
"description": "Bizppurio (Daou Tech) SMS/LMS and KakaoTalk alimtalk messaging plugin.",
"settings": {
"title": "Bizppurio Messaging Settings",
"description": "Manage Bizppurio integration credentials and sending options.",
"save": "Save",
"saving": "Saving...",
"save_success": "Settings saved.",
"save_failed": "Failed to save settings.",
"sections": {
"api": {
"title": "API Integration",
"description": "Credentials shared by the sending system and the Kakao management system."
},
"sending": {
"title": "Sending Options",
"description": "Sender information used for SMS and alimtalk delivery."
},
"integration": {
"title": "Integration Guide",
"description": "The webhook address that receives delivery results."
}
},
"fields": {
"environment": {
"label": "Environment",
"hint": "Select the inspection (dev) or production environment. The sending API domain differs by environment.",
"option_dev": "Inspection (Dev)",
"option_live": "Production"
},
"bizppurio_id": {
"label": "Bizppurio ID",
"hint": "The Bizppurio account ID used for both sending and Kakao management."
},
"password": {
"label": "Password",
"hint": "The Bizppurio password used to issue the sending token."
},
"api_key": {
"label": "API Key",
"hint": "The API key is issued after you submit it together with your ID to Bizppurio customer support."
},
"sender_number": {
"label": "Sender Number",
"hint": "The sender phone number used for SMS and alimtalk delivery."
},
"sender_key": {
"label": "Alimtalk Sender Profile Key",
"hint": "The 40-character sender profile key used for alimtalk delivery and template lookup."
}
},
"webhook": {
"label": "Delivery Result Address (webhook)",
"hint": "Register this address with the Bizppurio business team as the URL PUSH endpoint so delivery results are recorded automatically.",
"copy": "Copy",
"copied": "Copied."
}
}
}
@@ -0,0 +1,60 @@
{
"name": "비즈뿌리오 메시징",
"description": "비즈뿌리오(다우기술) 연동 SMS/LMS·카카오 알림톡 자동발송 플러그인입니다.",
"settings": {
"title": "비즈뿌리오 메시징 설정",
"description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.",
"save": "저장",
"saving": "저장 중...",
"save_success": "설정이 저장되었습니다.",
"save_failed": "설정 저장에 실패했습니다.",
"sections": {
"api": {
"title": "API 연동",
"description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다."
},
"sending": {
"title": "발송 설정",
"description": "문자·알림톡 발송에 사용하는 발신 정보입니다."
},
"integration": {
"title": "연동 안내",
"description": "발송 결과를 수신하는 webhook 주소입니다."
}
},
"fields": {
"environment": {
"label": "연동 환경",
"hint": "검수(개발) 또는 운영 환경을 선택합니다. 발송 API 도메인이 환경에 따라 분기됩니다.",
"option_dev": "검수(개발)",
"option_live": "운영"
},
"bizppurio_id": {
"label": "비즈뿌리오 아이디",
"hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다."
},
"password": {
"label": "비밀번호",
"hint": "발송 토큰 발급에 사용하는 비즈뿌리오 비밀번호입니다."
},
"api_key": {
"label": "API 키",
"hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다."
},
"sender_number": {
"label": "발신번호",
"hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다."
},
"sender_key": {
"label": "알림톡 발신프로필 키",
"hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다."
}
},
"webhook": {
"label": "발송 결과 수신 주소 (webhook)",
"hint": "비즈뿌리오 사업팀에 이 주소를 URL PUSH 수신 주소로 등록하면 발송 결과가 이력에 자동 기록됩니다.",
"copy": "복사",
"copied": "복사되었습니다."
}
}
}
@@ -0,0 +1,644 @@
{
"version": "1.0.0",
"layout_name": "plugin_settings",
"permissions": [
"core.plugins.update"
],
"extends": "_admin_base",
"meta": {
"title": "$t:sirsoft-message_bizppurio.settings.title",
"description": "$t:sirsoft-message_bizppurio.settings.description"
},
"data_sources": [
{
"id": "settings",
"type": "api",
"endpoint": "/api/admin/plugins/{{route.identifier}}/settings",
"method": "GET",
"auto_fetch": true,
"auth_required": true,
"initLocal": "form",
"refetchOnMount": true
}
],
"slots": {
"content": [
{
"id": "plugin_settings_content",
"type": "basic",
"name": "Div",
"dataKey": "form",
"trackChanges": true,
"props": {
"className": "p-4 sm:p-6 lg:p-8 min-h-screen bg-gray-50 dark:bg-gray-900"
},
"children": [
{
"id": "page_header",
"type": "composite",
"name": "PageHeader",
"props": {
"title": "$t:sirsoft-message_bizppurio.settings.title",
"description": "$t:sirsoft-message_bizppurio.settings.description",
"backPath": "/admin/plugins",
"backLabel": "$t:admin.plugins.back_to_list"
}
},
{
"id": "validation_error",
"type": "basic",
"name": "Div",
"if": "{{_local.errors}}",
"props": {
"id": "bizppurio_settings_validation_error",
"role": "alert",
"aria-live": "assertive",
"tabIndex": -1,
"className": "alert-danger scroll-mt-32"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-3"
},
"children": [
{
"type": "basic",
"name": "Icon",
"props": {
"name": "triangle-exclamation",
"size": "md",
"className": "text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
}
},
{
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-error-soft"
},
"text": "$t:admin.plugins.settings.validation_error"
},
{
"type": "basic",
"name": "Ul",
"if": "{{typeof _local.errors === 'object' && Object.keys(_local.errors).length > 0}}",
"props": {
"className": "mt-2 list-disc list-inside space-y-1"
},
"iteration": {
"source": "Object.entries(_local.errors ?? {}).flatMap(([field, messages]) => Array.isArray(messages) ? messages : [messages])",
"item_var": "message"
},
"children": [
{
"type": "basic",
"name": "Li",
"text": "{{message}}"
}
]
}
]
}
]
}
]
},
{
"id": "section_api",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-gray-100"
},
"text": "$t:sirsoft-message_bizppurio.settings.sections.api.title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-message_bizppurio.settings.sections.api.description"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 space-y-6"
},
"children": [
{
"id": "field_environment",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.environment.label"
},
{
"type": "basic",
"name": "Select",
"props": {
"name": "environment",
"className": "input mt-1"
},
"children": [
{
"type": "basic",
"name": "Option",
"props": {
"value": "dev"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_dev"
},
{
"type": "basic",
"name": "Option",
"props": {
"value": "live"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.environment.option_live"
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mt-1"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.environment.hint"
}
]
},
{
"id": "field_bizppurio_id",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "bizppurio_id",
"autoComplete": "off",
"className": "input mt-1"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mt-1"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint"
}
]
},
{
"id": "field_password",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.password.label"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "password",
"name": "password",
"autoComplete": "new-password",
"placeholder": "••••••••",
"className": "input mt-1 font-mono"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mt-1"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint"
}
]
},
{
"id": "field_api_key",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "password",
"name": "api_key",
"autoComplete": "new-password",
"placeholder": "••••••••",
"className": "input mt-1 font-mono"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mt-1"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint"
}
]
}
]
}
]
},
{
"id": "section_sending",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-gray-100"
},
"text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-message_bizppurio.settings.sections.sending.description"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 space-y-6"
},
"children": [
{
"id": "field_sender_number",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"name": "sender_number",
"autoComplete": "off",
"className": "input mt-1"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mt-1"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint"
}
]
},
{
"id": "field_sender_key",
"type": "basic",
"name": "Div",
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label"
},
{
"type": "basic",
"name": "Input",
"props": {
"type": "password",
"name": "sender_key",
"autoComplete": "new-password",
"placeholder": "••••••••",
"className": "input mt-1 font-mono"
}
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint mt-1"
},
"text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint"
}
]
}
]
}
]
},
{
"id": "section_integration",
"type": "basic",
"name": "Div",
"props": {
"className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "Div",
"props": {
"className": "px-6 py-4 border-b border-gray-200 dark:border-gray-700"
},
"children": [
{
"type": "basic",
"name": "H3",
"props": {
"className": "text-base font-semibold text-gray-900 dark:text-gray-100"
},
"text": "$t:sirsoft-message_bizppurio.settings.sections.integration.title"
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-message_bizppurio.settings.sections.integration.description"
}
]
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "p-6 space-y-2"
},
"children": [
{
"type": "basic",
"name": "Label",
"props": {
"className": "form-label"
},
"text": "$t:sirsoft-message_bizppurio.settings.webhook.label"
},
{
"type": "basic",
"name": "Div",
"props": {
"className": "flex gap-2"
},
"children": [
{
"type": "basic",
"name": "Input",
"props": {
"type": "text",
"readOnly": true,
"value": "/api/plugins/sirsoft-message_bizppurio/webhook",
"className": "input flex-1 font-mono text-sm bg-gray-50 dark:bg-gray-900"
}
}
]
},
{
"type": "basic",
"name": "P",
"props": {
"className": "form-hint"
},
"text": "$t:sirsoft-message_bizppurio.settings.webhook.hint"
}
]
}
]
},
{
"id": "footer_buttons",
"type": "basic",
"name": "Div",
"props": {
"className": "sticky bottom-0 z-10 flex justify-end items-center gap-3 mt-6 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg dark:bg-gray-800 dark:border-gray-700"
},
"children": [
{
"id": "cancel_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"className": "btn btn-secondary"
},
"actions": [
{
"type": "click",
"handler": "navigate",
"params": {
"path": "/admin/plugins"
}
}
],
"children": [
{
"type": "basic",
"name": "Span",
"text": "$t:common.cancel"
}
]
},
{
"id": "save_button",
"type": "basic",
"name": "Button",
"props": {
"type": "button",
"disabled": "{{!_local.hasChanges || _local.isSaving}}",
"className": "btn btn-primary flex-center gap-1.5"
},
"actions": [
{
"type": "click",
"handler": "sequence",
"actions": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": true,
"errors": null
}
},
{
"handler": "apiCall",
"auth_required": true,
"target": "/api/admin/plugins/{{route.identifier}}/settings",
"params": {
"method": "PUT",
"body": "{{_local.form}}"
},
"onSuccess": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"hasChanges": false
}
},
{
"handler": "toast",
"params": {
"type": "success",
"message": "$t:admin.plugins.settings.save_success"
}
},
{
"handler": "refetchDataSource",
"params": {
"dataSourceId": "settings"
}
}
],
"onError": [
{
"handler": "setState",
"params": {
"target": "local",
"isSaving": false,
"errors": "{{error.errors ?? { _general: error.message }}}"
}
},
{
"handler": "scrollIntoView",
"params": {
"selector": "#bizppurio_settings_validation_error",
"behavior": "smooth",
"block": "start",
"waitForElement": true,
"timeout": 2000
}
},
{
"handler": "toast",
"params": {
"type": "error",
"message": "$t:admin.plugins.settings.save_error"
}
}
]
}
]
}
],
"children": [
{
"type": "basic",
"name": "Icon",
"if": "{{_local.isSaving}}",
"props": {
"name": "spinner",
"className": "spinner-sm"
}
},
{
"type": "basic",
"name": "Span",
"if": "{{!_local.isSaving}}",
"text": "$t:common.save"
},
{
"type": "basic",
"name": "Span",
"if": "{{_local.isSaving}}",
"text": "$t:common.saving"
}
]
}
]
}
]
}
]
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 발송 채널 — bizppurio_dispatches.channel 컬럼의 값 집합.
*
* SMS/LMS 는 문자, 알림톡은 카카오 채널. 실제 발송 유형(SMS/LMS 자동 분기)은
* SmsTypeResolver(Phase 2)가 byte 판별로 확정한다.
*/
enum DispatchChannel: string
{
case Sms = 'sms';
case Lms = 'lms';
case Alimtalk = 'alimtalk';
/**
* 현재 locale 에 맞는 채널 라벨을 반환합니다.
*
* @return string 다국어 채널 라벨 (예: "SMS", "LMS", "알림톡")
*/
public function label(): string
{
return match ($this) {
self::Sms => __('sirsoft-message_bizppurio::messages.channel.sms'),
self::Lms => __('sirsoft-message_bizppurio::messages.channel.lms'),
self::Alimtalk => __('sirsoft-message_bizppurio::messages.channel.alimtalk'),
};
}
/**
* 문자(SMS/LMS) 채널 여부를 반환합니다.
*
* @return bool 문자 채널이면 true, 알림톡이면 false
*/
public function isText(): bool
{
return $this === self::Sms || $this === self::Lms;
}
/**
* 모든 채널 값 목록을 반환합니다.
*
* @return array<int, string> 채널 문자열 배열 (예: ["sms", "lms", "alimtalk"])
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 발송 출처 — bizppurio_dispatches.source 컬럼의 값 집합.
*
* 1차 범위는 auto(코어 알림 이벤트 자동발송)만 사용한다.
* manual(수동발송)·bulk(대량발송)은 후속(D15).
*/
enum DispatchSource: string
{
case Auto = 'auto';
case Manual = 'manual';
case Bulk = 'bulk';
/**
* 현재 locale 에 맞는 출처 라벨을 반환합니다.
*
* @return string 다국어 출처 라벨 (예: "자동", "수동", "대량")
*/
public function label(): string
{
return match ($this) {
self::Auto => __('sirsoft-message_bizppurio::messages.source.auto'),
self::Manual => __('sirsoft-message_bizppurio::messages.source.manual'),
self::Bulk => __('sirsoft-message_bizppurio::messages.source.bulk'),
};
}
/**
* 모든 출처 값 목록을 반환합니다.
*
* @return array<int, string> 출처 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 발송 상태 — bizppurio_dispatches.status 컬럼의 값 집합.
*
* pending(대기) → sent(발송요청 성공, 리포트 대기) → success/failed(webhook 리포트 확정).
* webhook 결과 수신(Phase 4) 전까지는 sent 상태로 머문다.
*/
enum DispatchStatus: string
{
case Pending = 'pending';
case Sent = 'sent';
case Success = 'success';
case Failed = 'failed';
/**
* 현재 locale 에 맞는 상태 라벨을 반환합니다.
*
* @return string 다국어 상태 라벨 (예: "대기", "발송중", "성공", "실패")
*/
public function label(): string
{
return match ($this) {
self::Pending => __('sirsoft-message_bizppurio::messages.status.pending'),
self::Sent => __('sirsoft-message_bizppurio::messages.status.sent'),
self::Success => __('sirsoft-message_bizppurio::messages.status.success'),
self::Failed => __('sirsoft-message_bizppurio::messages.status.failed'),
};
}
/**
* 리포트 수신으로 상태가 확정되었는지(성공/실패) 여부를 반환합니다.
*
* @return bool 성공 또는 실패이면 true, 대기/발송중이면 false
*/
public function isFinal(): bool
{
return $this === self::Success || $this === self::Failed;
}
/**
* 모든 상태 값 목록을 반환합니다.
*
* @return array<int, string> 상태 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Enums;
/**
* 결과코드 분류 — 비즈뿌리오 결과코드를 처리 방침으로 분류한 값 집합.
*
* ResultCodeResolver(Phase 4)가 코드 → 이 분류로 매핑하고, 발송 재시도 정책
* (SendMessageJob, Phase 2)과 잔액부족 자체알림(Phase 4)의 판단 기준이 된다.
*
* - success: 발송/리포트 성공
* - retry: 일시 오류 → 재시도 대상
* - permanent_failure: 영구 실패 → 즉시 실패 처리
* - balance_low: 지갑 잔액 부족(9070 문자 / 7436 알림톡) → 실패 + 관리자 자체알림
*/
enum ResultCategory: string
{
case Success = 'success';
case Retry = 'retry';
case PermanentFailure = 'permanent_failure';
case BalanceLow = 'balance_low';
/**
* 현재 locale 에 맞는 분류 라벨을 반환합니다.
*
* @return string 다국어 분류 라벨
*/
public function label(): string
{
return match ($this) {
self::Success => __('sirsoft-message_bizppurio::messages.result_category.success'),
self::Retry => __('sirsoft-message_bizppurio::messages.result_category.retry'),
self::PermanentFailure => __('sirsoft-message_bizppurio::messages.result_category.permanent_failure'),
self::BalanceLow => __('sirsoft-message_bizppurio::messages.result_category.balance_low'),
};
}
/**
* 재시도 대상 분류 여부를 반환합니다.
*
* @return bool 일시 오류(retry)이면 true
*/
public function isRetryable(): bool
{
return $this === self::Retry;
}
/**
* 발송 실패로 확정되는 분류 여부를 반환합니다.
*
* @return bool 영구 실패 또는 잔액 부족이면 true
*/
public function isFailure(): bool
{
return $this === self::PermanentFailure || $this === self::BalanceLow;
}
/**
* 모든 분류 값 목록을 반환합니다.
*
* @return array<int, string> 분류 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Plugins\Sirsoft\MessageBizppurio\Providers;
use App\Extension\BasePluginServiceProvider;
/**
* 비즈뿌리오 메시징 플러그인 서비스 프로바이더.
*
* BasePluginServiceProvider 가 Repository / Storage / Cache 자동 바인딩과
* 다국어 로드를 처리한다. 아래 배열에 등록만 하면 contextual binding 이 적용된다.
*/
class MessageBizppurioServiceProvider extends BasePluginServiceProvider
{
/** 플러그인 식별자 (manifest 와 일치) */
protected string $pluginIdentifier = 'sirsoft-message_bizppurio';
/**
* Repository 인터페이스 ↔ 구현체 매핑.
*
* Phase 4 에서 발송 이력·이벤트 연동 Repository 를 등록한다.
*
* @var array<class-string, class-string>
*/
protected array $repositories = [
// Phase 4: BizppurioDispatchRepositoryInterface / BizppurioNotificationBindingRepositoryInterface
];
/**
* CacheInterface 가 필요한 서비스 (contextual binding).
*
* Phase 2 에서 발송 토큰 캐시 서비스(BizppurioTokenService)를 등록한다.
*
* @var array<int, class-string>
*/
protected array $cacheServices = [
// Phase 2: BizppurioTokenService::class
];
/**
* 플러그인 루트 lang 디렉토리를 로드합니다.
*
* 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()`
* (= 플러그인 식별자) 네임스페이스로 로드한다.
*
* @return void
*/
protected function loadExtensionTranslations(): void
{
$langPath = dirname($this->getProviderPath(), 2).'/lang';
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, $this->translationNamespace());
}
}
}
@@ -0,0 +1,140 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Feature\Installation;
use App\Enums\ExtensionOwnerType;
use App\Models\Menu;
use Plugins\Sirsoft\MessageBizppurio\Plugin;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 비즈뿌리오 메시징 플러그인 설치 스모크 테스트.
*
* plugin.php 가 선언하는 권한/메뉴/알림 정의/설정 스키마 구조와
* activate/deactivate 의 메뉴 동기화·정리 동작을 검증한다.
*/
class InstallationTest extends PluginTestCase
{
private Plugin $plugin;
protected function setUp(): void
{
parent::setUp();
$this->plugin = new Plugin;
}
public function test_식별자가_디렉토리명과_일치한다(): void
{
$this->assertSame('sirsoft-message_bizppurio', $this->plugin->getIdentifier());
$this->assertSame('sirsoft', $this->plugin->getVendor());
}
public function test_권한이_view_manage_2개로_선언된다(): void
{
$permissions = $this->plugin->getPermissions();
$this->assertArrayHasKey('categories', $permissions);
$this->assertCount(1, $permissions['categories']);
$category = $permissions['categories'][0];
$this->assertSame('messaging', $category['identifier']);
$actions = array_column($category['permissions'], 'action');
$this->assertEqualsCanonicalizing(['view', 'manage'], $actions);
foreach ($category['permissions'] as $permission) {
$this->assertContains('admin', $permission['roles']);
$this->assertSame('admin', $permission['type']);
}
}
public function test_설정_스키마의_크리덴셜은_sensitive다(): void
{
$schema = $this->plugin->getSettingsSchema();
foreach (['password', 'api_key', 'sender_key'] as $credential) {
$this->assertArrayHasKey($credential, $schema);
$this->assertTrue(
$schema[$credential]['sensitive'] ?? false,
"{$credential} 는 sensitive 로 마킹되어야 한다."
);
}
// 비크리덴셜 필드는 존재하되 sensitive 아님
$this->assertArrayHasKey('bizppurio_id', $schema);
$this->assertArrayHasKey('sender_number', $schema);
$this->assertArrayHasKey('environment', $schema);
$this->assertSame('dev', $schema['environment']['default']);
}
public function test_defaults_json의_크리덴셜은_expose_false다(): void
{
$path = dirname(__DIR__, 3).'/config/settings/defaults.json';
$this->assertFileExists($path);
$defaults = json_decode(file_get_contents($path), true);
$schema = $defaults['frontend_schema'];
foreach ($schema as $field => $rule) {
$this->assertFalse(
$rule['expose'] ?? true,
"{$field} 는 프론트에 노출(expose:true)되면 안 된다."
);
}
foreach (['password', 'api_key', 'sender_key'] as $credential) {
$this->assertTrue($schema[$credential]['sensitive'] ?? false);
}
}
public function test_잔액부족_알림이_관리자_대상으로_정의된다(): void
{
$definitions = $this->plugin->getNotificationDefinitions();
$this->assertCount(1, $definitions);
$definition = $definitions[0];
$this->assertSame('bizppurio_balance_low', $definition['type']);
$this->assertSame('sirsoft-message_bizppurio', $definition['hook_prefix']);
$this->assertEqualsCanonicalizing(['mail', 'database'], $definition['channels']);
foreach ($definition['templates'] as $template) {
$recipients = $template['recipients'];
$this->assertSame('role', $recipients[0]['type']);
$this->assertSame('admin', $recipients[0]['value']);
}
}
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(),
'비활성화 시 플러그인 소속 메뉴가 전부 제거되어야 한다.'
);
}
}
@@ -0,0 +1,107 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
/**
* 비즈뿌리오 메시징 플러그인 테스트 베이스 클래스
*
* 모든 플러그인 테스트는 이 클래스를 상속받아야 합니다.
* 코어 + 번들 확장 마이그레이션을 자동으로 처리합니다.
*/
abstract class PluginTestCase extends TestCase
{
use RefreshDatabase;
/**
* HookManager static state 스냅샷 — tearDown 에서 복원하여 테스트 간 훅 격리 보장.
*
* @var array{hooks: array, filters: array, dispatching: array}|null
*/
private ?array $hookSnapshot = null;
/**
* 테스트 환경 설정
*
* @return void
*/
protected function setUp(): void
{
parent::setUp();
$this->snapshotHookManager();
}
/**
* tearDown 에 HookManager 상태 복원.
*
* @return void
*/
protected function tearDown(): void
{
$this->restoreHookManager();
parent::tearDown();
}
/**
* HookManager static $hooks / $filters / $dispatching 를 스냅샷.
*
* @return void
*/
private function snapshotHookManager(): void
{
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
$this->hookSnapshot = [
'hooks' => $ref->getProperty('hooks')->getValue(),
'filters' => $ref->getProperty('filters')->getValue(),
'dispatching' => $ref->getProperty('dispatching')->getValue(),
];
}
/**
* 스냅샷 시점으로 HookManager 복원.
*
* @return void
*/
private function restoreHookManager(): void
{
if ($this->hookSnapshot === null) {
return;
}
$ref = new \ReflectionClass(\App\Extension\HookManager::class);
$ref->getProperty('hooks')->setValue(null, $this->hookSnapshot['hooks']);
$ref->getProperty('filters')->setValue(null, $this->hookSnapshot['filters']);
$ref->getProperty('dispatching')->setValue(null, $this->hookSnapshot['dispatching']);
$this->hookSnapshot = null;
}
/**
* 마이그레이션 경로를 반환합니다.
*
* RefreshDatabase 의 migrate:fresh 명령에 코어 + 번들 확장 마이그레이션 경로를 전달합니다.
*
* @return array<string, mixed>
*/
protected function migrateFreshUsing(): array
{
$paths = ['database/migrations'];
foreach (glob(base_path('modules/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
foreach (glob(base_path('plugins/_bundled/*/database/migrations'), GLOB_ONLYDIR) as $p) {
$paths[] = str_replace(base_path().DIRECTORY_SEPARATOR, '', $p);
}
return [
'--drop-views' => $this->shouldDropViews(),
'--drop-types' => $this->shouldDropTypes(),
'--seed' => false,
'--path' => $paths,
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace Plugins\Sirsoft\MessageBizppurio\Tests\Unit\Enums;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchChannel;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchSource;
use Plugins\Sirsoft\MessageBizppurio\Enums\DispatchStatus;
use Plugins\Sirsoft\MessageBizppurio\Enums\ResultCategory;
use Plugins\Sirsoft\MessageBizppurio\Tests\PluginTestCase;
/**
* 발송 관련 Enum 4종 검증.
*/
class EnumTest extends PluginTestCase
{
public function test_dispatch_channel_값과_helper(): void
{
$this->assertEqualsCanonicalizing(['sms', 'lms', 'alimtalk'], DispatchChannel::values());
$this->assertTrue(DispatchChannel::Sms->isText());
$this->assertTrue(DispatchChannel::Lms->isText());
$this->assertFalse(DispatchChannel::Alimtalk->isText());
$this->assertNull(DispatchChannel::tryFrom('invalid'));
}
public function test_dispatch_status_값과_final_판정(): void
{
$this->assertEqualsCanonicalizing(['pending', 'sent', 'success', 'failed'], DispatchStatus::values());
$this->assertFalse(DispatchStatus::Pending->isFinal());
$this->assertFalse(DispatchStatus::Sent->isFinal());
$this->assertTrue(DispatchStatus::Success->isFinal());
$this->assertTrue(DispatchStatus::Failed->isFinal());
}
public function test_dispatch_source_값(): void
{
$this->assertEqualsCanonicalizing(['auto', 'manual', 'bulk'], DispatchSource::values());
$this->assertSame('auto', DispatchSource::Auto->value);
}
public function test_result_category_분류_판정(): void
{
$this->assertEqualsCanonicalizing(
['success', 'retry', 'permanent_failure', 'balance_low'],
ResultCategory::values()
);
$this->assertTrue(ResultCategory::Retry->isRetryable());
$this->assertFalse(ResultCategory::Success->isRetryable());
$this->assertTrue(ResultCategory::PermanentFailure->isFailure());
$this->assertTrue(ResultCategory::BalanceLow->isFailure());
$this->assertFalse(ResultCategory::Success->isFailure());
$this->assertFalse(ResultCategory::Retry->isFailure());
}
public function test_label은_다국어_문자열을_반환한다(): void
{
// label() 이 예외 없이 문자열을 반환하는지 (lang 키 해석 스모크)
$this->assertIsString(DispatchChannel::Sms->label());
$this->assertIsString(DispatchStatus::Success->label());
$this->assertIsString(DispatchSource::Auto->label());
$this->assertIsString(ResultCategory::BalanceLow->label());
}
}
@@ -0,0 +1,66 @@
# audit:allow test-scenario-coverage reason: |
# Phase 1 환경설정 화면은 입력·저장이 코어 /api/admin/plugins/{id}/settings 에 위임되는
# 단순 자동바인딩 폼이라, axes cross product 의 런타임 효과(저장 성공/실패 왕복)는 코어 설정
# 컨트롤러가 책임진다. 본 확장의 effects(3섹션 구조 / 6필드 / 크리덴셜 마스킹 / webhook 안내 /
# 저장 버튼 게이팅 / i18n 정합 / 권한·메뉴·알림 정의 / 설정 초기값 로드)는 PHPUnit 12건
# (InstallationTest·EnumTest) + Vitest 22건(plugin_settings.test.tsx) 정적 검증으로 가드된다.
# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 인증·발송 환경 필요)
# 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을 추가한다. 그때까지
# 매니페스트는 명세로 유지한다.
feature: 비즈뿌리오 메시징 환경설정 (Phase 1)
description: |
관리자 환경설정 화면(admin/plugin_settings.json)에서 비즈뿌리오 연동 정보를 입력·저장한다.
코어 플러그인 설정 인프라(/api/admin/plugins/{id}/settings)에 자동바인딩(_local.form)으로
위임하며, 3 섹션(API 연동 / 발송 설정 / 연동 안내)으로 구성된다.
핵심 동작:
- API 연동: 연동 환경(dev/live) + 비즈뿌리오 아이디 + 비밀번호 + API 키
- 발송 설정: 발신번호 + 알림톡 발신프로필 키
- 연동 안내: 발송 결과 수신 webhook 주소(readonly 표시)
- 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false
- 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT
- 설치·활성화 시 권한 2개(view/manage)·메뉴 3개·잔액부족 알림 정의 생성 + 설정 초기값 로드
※ 알림톡 채널 기본 body 시드(3영역)는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이므로
Phase 6 으로 이관(plugin.php getNotificationDefinitions 주석 참조).
axes:
credential_state: [empty, filled]
environment: [dev, live]
field_kind: [credential, plain, readonly]
exclusions:
- { field_kind: readonly, credential_state: filled, reason: "webhook 주소는 고정 표시값 — 운영자 입력 대상 아님" }
effects:
# 섹션 구조
- three_sections_api_sending_integration_present
# 입력 필드
- six_input_fields_bound_by_name
- credential_fields_masked_as_password
- plain_fields_use_text_input
- environment_select_has_dev_live_options
# 연동 안내
- webhook_address_shown_readonly
# 저장
- save_button_disabled_without_changes
- save_puts_to_core_plugin_settings_api
- only_registered_handlers_used
# 다국어
- all_referenced_i18n_keys_exist_in_ko_and_en
# 설치·활성화 (백엔드)
- install_creates_view_and_manage_permissions
- activate_creates_hierarchical_admin_menus
- deactivate_removes_plugin_menus
- balance_low_notification_targets_admin
- settings_defaults_load_with_dev_environment
test_files:
- plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx
- plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php
- plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php
audit:
status: codified
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"esModuleInterop": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["resources/js/*"]
}
},
"include": ["resources/js/**/*.ts", "resources/js/**/*.tsx"]
}
@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'path';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['resources/js/__tests__/**/*.test.{ts,tsx}'],
setupFiles: [],
},
resolve: {
alias: {
'@': resolve(__dirname, 'resources/js'),
},
},
});