From 1ba67ca7cfda3bb29a29507eefd1c0021970c7eb Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Sat, 22 Aug 2026 22:21:54 +0900
Subject: [PATCH 1/7] =?UTF-8?q?feat(content):=20=EB=B3=B8=EB=AC=B8=20?=
=?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=AA=A9=EB=A1=9D=20=EC=8D=B8?=
=?UTF-8?q?=EB=84=A4=EC=9D=BC=C2=B7og:image=20=ED=8F=B4=EB=B0=B1=20?=
=?UTF-8?q?=EC=A0=84=20=ED=8B=B0=EC=96=B4=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
공개 이슈 https://github.com/gnuboard/g7/issues/22 — 본문에 삽입한 이미지가
카드/갤러리 목록 썸네일과 공유 미리보기(og:image)에 반영되지 않던 공백을
코어+3모듈+2템플릿에서 해소.
- 코어 7.0.9: HtmlImageExtractor(내부 이미지 한정, origin 정규화 SSoT 재사용)
+ seo.og_image_default 사이트 기본 공유 이미지 설정 + SeoMetaResolver 폴백 체인
- board 1.1.0 / ecommerce 1.2.0 / page 1.1.0: content_thumbnail_url 캐시 컬럼,
saving 추출(html 모드 한정·첨부 우선·비밀글 게이트 유지),
filter_content_thumbnail 훅, 기존 데이터 백필 업그레이드 스텝
- ecommerce 카테고리 og:image 생산자 신설, page og:description 死키 정정
- sirsoft-basic 1.1.2 og 바인딩 정정, admin_basic 1.0.7 SEO 탭 업로더(+ja 팩 2종)
- 검수 중 발견한 text 모드 오캐시 결함은 실패 테스트 선행 후 수정
---
.env.example | 2 +-
.env.testing.example | 2 +-
CHANGELOG.md | 7 +
INSTALL.md | 2 +-
README.ko.md | 4 +-
README.md | 4 +-
.../Requests/Settings/SaveSettingsRequest.php | 17 ++
app/Seo/SeoMetaResolver.php | 14 +
app/Services/SettingsService.php | 130 ++++++++-
app/Support/HtmlImageExtractor.php | 183 ++++++++++++
config/app.php | 2 +-
config/settings/defaults.json | 3 +
docs/backend/api/settings.md | 1 +
docs/extension/hooks.md | 8 +
lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 6 +
.../g7-core-ja/backend/ja/validation.php | 1 +
.../_bundled/g7-core-ja/language-pack.json | 2 +-
.../CHANGELOG.md | 6 +
.../frontend/partial/admin.json | 2 +
.../language-pack.json | 2 +-
lang/en/validation.php | 1 +
lang/ko/validation.php | 1 +
modules/_bundled/sirsoft-board/CHANGELOG.md | 7 +
modules/_bundled/sirsoft-board/composer.json | 2 +-
...ent_thumbnail_url_to_board_posts_table.php | 43 +++
.../_bundled/sirsoft-board/docs/api/board.md | 14 +-
.../_bundled/sirsoft-board/docs/api/boards.md | 8 +-
modules/_bundled/sirsoft-board/module.json | 4 +-
.../_bundled/sirsoft-board/package-lock.json | 4 +-
modules/_bundled/sirsoft-board/package.json | 2 +-
.../src/Http/Resources/PostResource.php | 22 +-
.../sirsoft-board/src/Models/Post.php | 50 +++-
.../src/Repositories/PostRepository.php | 2 +-
.../Upgrade/ContentThumbnailBackfillTest.php | 169 +++++++++++
.../User/PostListContentThumbnailTest.php | 264 ++++++++++++++++++
.../tests/Playwright/fixtures/board-auth.ts | 18 ++
.../user/board-content-thumbnail.spec.ts | 241 ++++++++++++++++
.../Unit/Models/PostContentThumbnailTest.php | 258 +++++++++++++++++
.../PostResourceThumbnailUrlTest.php | 92 ++++++
.../board-content-thumbnail-fallback.yaml | 48 ++++
.../sirsoft-board/upgrades/Upgrade_1_1_0.php | 17 ++
.../01_BackfillContentThumbnails.php | 206 ++++++++++++++
.../_bundled/sirsoft-ecommerce/CHANGELOG.md | 10 +
.../_bundled/sirsoft-ecommerce/composer.json | 2 +-
...mbnail_url_to_ecommerce_products_table.php | 44 +++
.../sirsoft-ecommerce/docs/api/categories.md | 2 +-
.../sirsoft-ecommerce/docs/api/products.md | 20 +-
.../sirsoft-ecommerce/docs/api/wishlist.md | 2 +-
.../_bundled/sirsoft-ecommerce/module.json | 4 +-
modules/_bundled/sirsoft-ecommerce/module.php | 6 +-
.../sirsoft-ecommerce/package-lock.json | 4 +-
.../_bundled/sirsoft-ecommerce/package.json | 2 +-
.../PublicCategoryDetailResource.php | 6 +-
.../sirsoft-ecommerce/src/Models/Product.php | 69 ++++-
.../ProductListContentThumbnailTest.php | 226 +++++++++++++++
.../Feature/PublicCategoryThumbnailTest.php | 82 ++++++
.../ProductContentThumbnailBackfillTest.php | 151 ++++++++++
.../tests/Unit/EcommerceModuleSeoTest.php | 51 ++++
.../Models/ProductContentThumbnailTest.php | 206 ++++++++++++++
.../product-content-thumbnail-fallback.yaml | 53 ++++
.../upgrades/Upgrade_1_2_0.php | 17 ++
.../01_BackfillProductContentThumbnails.php | 238 ++++++++++++++++
.../sirsoft-ecommerce/vendor-bundle.json | 10 +-
.../sirsoft-ecommerce/vendor-bundle.zip | Bin 435549 -> 435548 bytes
modules/_bundled/sirsoft-page/CHANGELOG.md | 10 +
modules/_bundled/sirsoft-page/composer.json | 2 +-
...d_content_thumbnail_url_to_pages_table.php | 44 +++
.../_bundled/sirsoft-page/docs/api/pages.md | 1 +
modules/_bundled/sirsoft-page/module.json | 4 +-
.../_bundled/sirsoft-page/package-lock.json | 4 +-
modules/_bundled/sirsoft-page/package.json | 2 +-
.../src/Http/Resources/PublicPageResource.php | 2 +
.../_bundled/sirsoft-page/src/Models/Page.php | 68 ++++-
.../PageContentThumbnailBackfillTest.php | 126 +++++++++
.../User/PageContentThumbnailResourceTest.php | 71 +++++
.../specs/user/page-og-image.spec.ts | 109 ++++++++
.../Unit/Models/PageContentThumbnailTest.php | 173 ++++++++++++
.../tests/scenarios/page-og-image.yaml | 42 +++
.../sirsoft-page/upgrades/Upgrade_1_1_0.php | 17 ++
.../01_BackfillPageContentThumbnails.php | 235 ++++++++++++++++
.../_bundled/sirsoft-admin_basic/CHANGELOG.md | 6 +
.../lang/partial/en/admin.json | 2 +
.../lang/partial/ko/admin.json | 2 +
.../partials/admin_settings/_tab_seo.json | 103 ++++++-
.../sirsoft-admin_basic/package-lock.json | 4 +-
.../_bundled/sirsoft-admin_basic/package.json | 2 +-
.../sirsoft-admin_basic/template.json | 4 +-
templates/_bundled/sirsoft-basic/CHANGELOG.md | 10 +
...earch-product-item-list-thumbnail.test.tsx | 144 ++++++++++
.../sirsoft-basic/layouts/page/show.json | 8 +-
.../partials/search/products/_item_list.json | 18 +-
.../_bundled/sirsoft-basic/package-lock.json | 4 +-
templates/_bundled/sirsoft-basic/package.json | 2 +-
.../_bundled/sirsoft-basic/template.json | 6 +-
.../Settings/OgDefaultImageSettingTest.php | 126 +++++++++
tests/Unit/Seo/SeoMetaResolverTest.php | 84 ++++++
tests/Unit/Support/HtmlImageExtractorTest.php | 202 ++++++++++++++
tests/scenarios/seo-og-default-image.yaml | 28 ++
98 files changed, 4640 insertions(+), 101 deletions(-)
create mode 100644 app/Support/HtmlImageExtractor.php
create mode 100644 modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts
create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml
create mode 100644 modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php
create mode 100644 modules/_bundled/sirsoft-board/upgrades/data/1.1.0/migrations/01_BackfillContentThumbnails.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml
create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/data/1.2.0/migrations/01_BackfillProductContentThumbnails.php
create mode 100644 modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php
create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php
create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php
create mode 100644 modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts
create mode 100644 modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php
create mode 100644 modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml
create mode 100644 modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php
create mode 100644 modules/_bundled/sirsoft-page/upgrades/data/1.1.0/migrations/01_BackfillPageContentThumbnails.php
create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx
create mode 100644 tests/Feature/Settings/OgDefaultImageSettingTest.php
create mode 100644 tests/Unit/Support/HtmlImageExtractorTest.php
create mode 100644 tests/scenarios/seo-og-default-image.yaml
diff --git a/.env.example b/.env.example
index 51bc68e1..e80ed460 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.8
+APP_VERSION=7.0.9
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 7b8d989f..ae970d1e 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.8
+APP_VERSION=7.0.9
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 04df1e86..9c7c5b72 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.9] - 2026-08-22
+
+### Added
+
+- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
+- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
+
## [7.0.8] - 2026-08-22
### Added
diff --git a/INSTALL.md b/INSTALL.md
index 2946a231..257047c5 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -289,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.8 g7
+# (필요 시) mv g7-7.0.9 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.ko.md b/README.ko.md
index b0aa6a8d..a2f5d149 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -10,7 +10,7 @@
-
+
@@ -520,9 +520,9 @@ cp .env.example .env
+
-
diff --git a/README.md b/README.md
index fdd76c39..705d3ebc 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -534,9 +534,9 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
+
-
diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php
index 6ce8165a..12876fa3 100644
--- a/app/Http/Requests/Settings/SaveSettingsRequest.php
+++ b/app/Http/Requests/Settings/SaveSettingsRequest.php
@@ -97,6 +97,20 @@ class SaveSettingsRequest extends FormRequest
]);
}
+ // 사이트 기본 OG 이미지도 동일하게 첨부 객체 배열 → 정수 ID 배열로 정규화
+ $ogImageDefault = $this->input('seo.og_image_default');
+
+ if (is_array($ogImageDefault)) {
+ $this->merge([
+ 'seo' => array_merge($this->input('seo', []), [
+ 'og_image_default' => array_map(
+ fn ($item) => is_array($item) ? ($item['id'] ?? $item) : $item,
+ $ogImageDefault
+ ),
+ ]),
+ ]);
+ }
+
// GeoIP 토글 필드: 신규 필드라 초기값이 settings에 없을 수 있음.
// Toggle 컴포넌트가 undefined → true 전환 시 비표준 타입을 보낼 수 있으므로
// boolean으로 명시 캐스팅하여 Laravel boolean 검증 호환 보장.
@@ -243,6 +257,8 @@ class SaveSettingsRequest extends FormRequest
'seo.bot_detection_enabled' => ['nullable', 'boolean'],
'seo.bot_detection_library_enabled' => ['nullable', 'boolean'],
'seo.og_default_site_name' => ['nullable', 'string', 'max:200'],
+ 'seo.og_image_default' => ['nullable', 'array'],
+ 'seo.og_image_default.*' => ['integer', Rule::exists(Attachment::class, 'id')],
'seo.og_image_default_width' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_width_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_width_max', 8000)],
'seo.og_image_default_height' => ['nullable', 'integer', 'min:'.config('core.settings_limits.seo_og_image_default_height_min', 0), 'max:'.config('core.settings_limits.seo_og_image_default_height_max', 8000)],
'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])],
@@ -915,6 +931,7 @@ class SaveSettingsRequest extends FormRequest
'general.maintenance_mode' => __('validation.attributes.maintenance_mode'),
'general.asset_url_mode' => __('validation.attributes.asset_url_mode'),
'general.site_logo' => __('validation.attributes.site_logo'),
+ 'seo.og_image_default' => __('validation.attributes.og_image_default'),
// mail
'mail.mailer' => __('validation.attributes.mailer'),
'mail.host' => __('validation.attributes.smtp_host'),
diff --git a/app/Seo/SeoMetaResolver.php b/app/Seo/SeoMetaResolver.php
index 678ccbc3..4c56b19c 100644
--- a/app/Seo/SeoMetaResolver.php
+++ b/app/Seo/SeoMetaResolver.php
@@ -4,6 +4,7 @@ namespace App\Seo;
use App\Seo\Concerns\LocalizesSeoValues;
use App\Seo\Concerns\SubstitutesSeoVariables;
+use App\Services\SettingsService;
class SeoMetaResolver
{
@@ -515,6 +516,19 @@ class SeoMetaResolver
$title = $this->stripHtml($this->safeEval($og['title'] ?? '', $context)) ?: $fallbackTitle;
$description = $this->stripHtml($this->safeEval($og['description'] ?? '', $context)) ?: $fallbackDescription;
$image = $this->absoluteUrl($this->safeEval($og['image'] ?? '', $context));
+
+ // 사이트 기본 OG 이미지 폴백 (공개 이슈 #22) — 도메인 콘텐츠 폴백(본문 캐시)은
+ // 각 모듈 레이아웃/훅 바인딩이 공급하므로 코어는 사이트 기본값 한 단만 담당한다.
+ // 체인: 레이아웃 선언(도메인 캐시 포함) → 사이트 기본 이미지 → 빈 값(태그 미출력).
+ // secure_url 파생은 반드시 이 폴백 적용 후의 $image 기준이어야 한다.
+ if ($image === '') {
+ $defaultImage = app(SettingsService::class)->getOgDefaultImageUrl();
+
+ if (is_string($defaultImage) && $defaultImage !== '') {
+ $image = $this->absoluteUrl($defaultImage);
+ }
+ }
+
$secureUrl = isset($og['image_secure_url'])
? $this->absoluteUrl($this->safeEval($og['image_secure_url'], $context))
: ($image !== '' && str_starts_with($image, 'https://') ? $image : '');
diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php
index 80072b9d..c19904b7 100644
--- a/app/Services/SettingsService.php
+++ b/app/Services/SettingsService.php
@@ -147,6 +147,9 @@ class SettingsService
// 첨부파일 정보 추가 (general 카테고리에)
$settings['general']['site_logo'] = $this->getSiteLogoAttachment();
+ // 사이트 기본 OG 이미지 첨부 정보 (seo 카테고리에 — site_logo 와 동형)
+ $settings['seo']['og_image_default'] = $this->getAttachmentListSetting('seo.og_image_default');
+
return $settings;
}
@@ -437,16 +440,30 @@ class SettingsService
*/
private function getSiteLogoAttachment(): array
{
- // JSON 설정에서 site_logo ID 배열 조회
- $siteLogoIds = $this->configRepository->get('general.site_logo', []);
+ return $this->getAttachmentListSetting('general.site_logo');
+ }
+
+ /**
+ * 첨부 ID 배열 설정을 첨부 목록(리소스 배열)으로 해석합니다.
+ *
+ * site_logo / og_image_default 처럼 "첨부 ID 배열 + 화면은 첨부 객체" 패턴의
+ * 설정이 공유하는 로드 경로입니다.
+ *
+ * @param string $configKey 설정 키 (예: 'general.site_logo')
+ * @return array 첨부 목록 (없으면 빈 배열)
+ */
+ private function getAttachmentListSetting(string $configKey): array
+ {
+ // JSON 설정에서 첨부 ID 배열 조회
+ $ids = $this->configRepository->get($configKey, []);
// 빈 배열이거나 배열이 아닌 경우
- if (empty($siteLogoIds) || ! is_array($siteLogoIds)) {
+ if (empty($ids) || ! is_array($ids)) {
return [];
}
// ID 배열로 첨부파일 조회 (DB order 기준 정렬)
- $attachments = $this->attachmentRepository->findByIds($siteLogoIds);
+ $attachments = $this->attachmentRepository->findByIds($ids);
if ($attachments->isEmpty()) {
return [];
@@ -455,6 +472,21 @@ class SettingsService
return $attachments->map(fn ($attachment) => (new AttachmentResource($attachment))->toListArray())->toArray();
}
+ /**
+ * 사이트 기본 OG 이미지 URL 을 반환합니다.
+ *
+ * SeoMetaResolver 가 레이아웃 og.image 선언(도메인 캐시 포함)이 빈 값일 때
+ * 마지막 폴백으로 사용합니다 (공개 이슈 #22 — 사이트 기본 공유 이미지).
+ *
+ * @return string|null 첫 번째 이미지 첨부의 다운로드 URL (미설정 시 null)
+ */
+ public function getOgDefaultImageUrl(): ?string
+ {
+ $ids = $this->configRepository->get('seo.og_image_default', []);
+
+ return $this->getFirstImageUrl(is_array($ids) ? $ids : []);
+ }
+
/**
* 첨부파일 ID 배열에서 첫 번째 이미지의 URL을 반환합니다.
*
@@ -530,6 +562,22 @@ class SettingsService
$tabSettings['site_logo'] = $this->resolveSiteLogoIds($tabSettings['site_logo']);
}
+ // 사이트 기본 OG 이미지도 site_logo 와 동형으로 처리 (공개 이슈 #22)
+ $removedOgImageIds = [];
+
+ if ($tab === 'seo' && is_array($tabSettings['og_image_default'] ?? null)) {
+ $removedOgImageIds = $this->resolveRemovedAttachmentIds(
+ 'seo',
+ 'og_image_default',
+ $tabSettings['og_image_default']
+ );
+
+ $tabSettings['og_image_default'] = $this->resolveSubmittedAttachmentIds(
+ 'og_image_default',
+ $tabSettings['og_image_default']
+ );
+ }
+
// 기존 설정과 병합 (탭별로 일부 필드만 전송되어도 기존 설정 유지)
$existingSettings = $this->configRepository->getCategory($tab);
$mergedSettings = array_merge($existingSettings, $tabSettings);
@@ -548,6 +596,7 @@ class SettingsService
// 저장이 확정된 뒤에야 파일을 파기한다 (위 판정 시점 주석 참조).
$this->purgeSiteLogoAttachments($removedSiteLogoIds);
+ $this->purgeRemovedAttachments($removedOgImageIds, '기본 OG 이미지');
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
// 모드가 바뀌면 그 URL 들이 전부 어긋나는데, 봇은 JavaScript 를 실행하지
@@ -709,6 +758,18 @@ class SettingsService
* @return array 저장할 첨부파일 ID 배열
*/
private function resolveSiteLogoIds(array $submitted): array
+ {
+ return $this->resolveSubmittedAttachmentIds('site_logo', $submitted);
+ }
+
+ /**
+ * 제출된 첨부 목록에서 해당 컬렉션에 실재하는 첨부 ID 만 남깁니다.
+ *
+ * @param string $collection 첨부 컬렉션명 (예: 'site_logo', 'og_image_default')
+ * @param array $submitted 제출값 (첨부 객체 배열 또는 ID 배열)
+ * @return array 저장할 첨부 ID 목록
+ */
+ private function resolveSubmittedAttachmentIds(string $collection, array $submitted): array
{
$submittedIds = $this->extractAttachmentIds($submitted);
@@ -716,13 +777,72 @@ class SettingsService
return [];
}
- $existingIds = $this->attachmentRepository->getByCollection('site_logo')
+ $existingIds = $this->attachmentRepository->getByCollection($collection)
->pluck('id')
->all();
return array_values(array_intersect($submittedIds, $existingIds));
}
+ /**
+ * 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 첨부 설정 ID 를 가려냅니다.
+ *
+ * 판정 기준·시점 규율은 resolveRemovedSiteLogoIds 와 동일합니다 — 직전 저장값에
+ * 있었는데 이번 제출에서 빠진 id 만 파기 대상이며, 저장으로 값이 덮이기 전에
+ * 판정해야 합니다.
+ *
+ * @param string $category 설정 카테고리 (예: 'seo')
+ * @param string $key 설정 키 (예: 'og_image_default')
+ * @param mixed $submitted 제출값 (미제출이면 null)
+ * @return array 파기 대상 첨부 ID 목록
+ */
+ private function resolveRemovedAttachmentIds(string $category, string $key, mixed $submitted): array
+ {
+ if (! is_array($submitted)) {
+ return [];
+ }
+
+ $previousIds = $this->extractAttachmentIds(
+ $this->configRepository->getCategory($category)[$key] ?? []
+ );
+
+ if ($previousIds === []) {
+ return [];
+ }
+
+ $keptIds = $this->extractAttachmentIds($submitted);
+
+ return array_values(array_diff($previousIds, $keptIds));
+ }
+
+ /**
+ * 제거가 확정된 설정 첨부를 파일까지 파기합니다 (저장 성공 후에만 호출).
+ *
+ * @param array $removedIds 파기 대상 첨부 ID 목록
+ * @param string $label 로그 표기용 설정 이름
+ */
+ private function purgeRemovedAttachments(array $removedIds, string $label): void
+ {
+ if ($removedIds === []) {
+ return;
+ }
+
+ foreach ($removedIds as $removedId) {
+ // 설정 저장은 이미 확정된 뒤다 — 파기 실패가 저장 실패(422)로 위장되면
+ // 운영자는 성공한 저장을 실패로 오인한다. 실패 파일은 로그로만 남긴다.
+ try {
+ $this->attachmentService->delete($removedId);
+ } catch (\Exception $e) {
+ Log::warning($label.' 첨부 파기 실패 — 저장은 확정됨', [
+ 'attachment_id' => $removedId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ Log::info($label.' 첨부 제거', ['attachment_ids' => $removedIds]);
+ }
+
/**
* 저장 요청에서 빠진(= 운영자가 화면에서 제거한) 사이트 로고 첨부 ID 를 가려냅니다.
*
diff --git a/app/Support/HtmlImageExtractor.php b/app/Support/HtmlImageExtractor.php
new file mode 100644
index 00000000..e92bae4e
--- /dev/null
+++ b/app/Support/HtmlImageExtractor.php
@@ -0,0 +1,183 @@
+` 의 `src` 속성을 문서 순서로 수집합니다.
+ *
+ * ext-dom 부재·파싱 실패 시에는 안전측(빈 배열)으로 폴백합니다.
+ *
+ * @param string $html 본문 HTML
+ * @return array src 속성값 목록 (빈 값 제외, 문서 순서)
+ */
+ public static function candidates(string $html): array
+ {
+ if (trim($html) === '') {
+ return [];
+ }
+
+ if (! class_exists(\DOMDocument::class)) {
+ return [];
+ }
+
+ $document = new \DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+
+ try {
+ $loaded = $document->loadHTML(
+ ''.$html.'
',
+ LIBXML_NOERROR | LIBXML_NOWARNING
+ );
+
+ if (! $loaded) {
+ return [];
+ }
+
+ $sources = [];
+
+ foreach ($document->getElementsByTagName('img') as $img) {
+ if (! $img instanceof \DOMElement) {
+ continue;
+ }
+
+ $src = trim($img->getAttribute('src'));
+
+ if ($src !== '') {
+ $sources[] = $src;
+ }
+ }
+
+ return $sources;
+ } catch (\Throwable $e) {
+ Log::warning('본문 이미지 추출 실패 — 후보 없음으로 폴백', [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return [];
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+ }
+
+ /**
+ * 본문 HTML 에서 첫 번째 **내부** 이미지 URL 을 반환합니다.
+
+ * 내부 판정 규칙 (후보를 문서 순서로 순회하며 첫 통과 값):
+ * 1. {@see TrustedScriptHosts::normalizeForOriginCheck()} 로 정규화
+ * 2. 정규화 결과 `//` 시작(protocol-relative) → 외부로 제외, `/` 단일 시작 → 내부
+ * 3. http(s) 절대 URL 은 host 가 `config('app.url')` host 와 동일(대소문자 무시)할
+ * 때만 통과하고 path 부터의 상대 형태로 변환해 반환 (응답 절대화는 소비측 규약)
+ * 4. `$extraAllowedPrefixes` 중 하나로 시작(정규화 후 비교)하면 통과 — CDN 스토리지
+ * 확장용이며 이 경우 원형 그대로 반환
+ * 5. `data:` / `javascript:` 등 비 http(s)·비 path 스킴은 전부 제외
+ *
+ * @param string $html 본문 HTML
+ * @param array $extraAllowedPrefixes 추가 허용 URL prefix 목록
+ * @return string|null 첫 내부 이미지 URL (후보 없으면 null)
+ */
+ public static function firstInternal(string $html, array $extraAllowedPrefixes = []): ?string
+ {
+ foreach (self::candidates($html) as $src) {
+ $resolved = self::resolveInternal($src, $extraAllowedPrefixes);
+
+ if ($resolved === null) {
+ continue;
+ }
+
+ // 컬럼 상한 초과 URL 은 캐시 불가 — 다음 후보로 넘어간다
+ if (mb_strlen($resolved) > self::MAX_URL_LENGTH) {
+ continue;
+ }
+
+ return $resolved;
+ }
+
+ return null;
+ }
+
+ /**
+ * 단일 src 후보를 내부 URL 로 해석합니다.
+ *
+ * @param string $src img src 속성값
+ * @param array $extraAllowedPrefixes 추가 허용 URL prefix 목록
+ * @return string|null 내부로 판정된 URL (외부/비허용 스킴이면 null)
+ */
+ private static function resolveInternal(string $src, array $extraAllowedPrefixes): ?string
+ {
+ $trimmed = trim($src);
+
+ if ($trimmed === '') {
+ return null;
+ }
+
+ $normalized = TrustedScriptHosts::normalizeForOriginCheck($trimmed);
+
+ if ($normalized === '') {
+ return null;
+ }
+
+ // protocol-relative 는 외부 origin
+ if (str_starts_with($normalized, '//')) {
+ return null;
+ }
+
+ // 단일 슬래시 시작 = same-origin 경로
+ if (str_starts_with($normalized, '/')) {
+ return $normalized;
+ }
+
+ $scheme = parse_url($normalized, PHP_URL_SCHEME);
+
+ if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
+ $host = parse_url($normalized, PHP_URL_HOST);
+ $appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
+
+ if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
+ $path = parse_url($normalized, PHP_URL_PATH);
+ $query = parse_url($normalized, PHP_URL_QUERY);
+
+ return (is_string($path) && $path !== '' ? $path : '/')
+ .(is_string($query) && $query !== '' ? '?'.$query : '');
+ }
+ }
+
+ // 확장이 선언한 허용 prefix (CDN 직접 URL 스토리지 등) — 원형 유지 반환
+ foreach ($extraAllowedPrefixes as $prefix) {
+ if (is_string($prefix) && $prefix !== '' && str_starts_with($normalized, $prefix)) {
+ return $trimmed;
+ }
+ }
+
+ // 그 외 스킴(data:/javascript:/blob: 등)·미허용 host 전부 제외
+ return null;
+ }
+}
diff --git a/config/app.php b/config/app.php
index d1a68dcd..c6d64ca0 100644
--- a/config/app.php
+++ b/config/app.php
@@ -231,7 +231,7 @@ return [
|
*/
- 'version' => env('APP_VERSION', '7.0.8'),
+ 'version' => env('APP_VERSION', '7.0.9'),
/*
|--------------------------------------------------------------------------
diff --git a/config/settings/defaults.json b/config/settings/defaults.json
index dd001aef..7761070b 100644
--- a/config/settings/defaults.json
+++ b/config/settings/defaults.json
@@ -64,6 +64,7 @@
"bot_detection_enabled": true,
"bot_detection_library_enabled": true,
"og_default_site_name": "",
+ "og_image_default": [],
"og_image_default_width": 1200,
"og_image_default_height": 630,
"twitter_default_card": "summary_large_image",
@@ -225,6 +226,8 @@
"bot_detection_enabled": { "type": "boolean", "sensitive": false },
"bot_detection_library_enabled": { "type": "boolean", "sensitive": false },
"og_default_site_name": { "type": "string", "sensitive": false },
+ "og_image_default": { "type": "array", "sensitive": false, "transform": "attachments" },
+ "og_image_default_url": { "type": "string", "sensitive": false, "computed": true, "source": "og_image_default", "transform": "first_image_url" },
"og_image_default_width": { "type": "integer", "sensitive": false },
"og_image_default_height": { "type": "integer", "sensitive": false },
"twitter_default_card": { "type": "string", "sensitive": false },
diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md
index 33bae599..b71f9cdc 100644
--- a/docs/backend/api/settings.md
+++ b/docs/backend/api/settings.md
@@ -214,6 +214,7 @@ HTTP/1.1 200
| seo.bot_detection_enabled | body | boolean | 아니오 | — | 검색엔진 봇 감지 사용 여부 (봇 요청에 SEO 렌더링 적용) |
| seo.bot_detection_library_enabled | body | boolean | 아니오 | — | 봇 감지 라이브러리 사용 여부 (User-Agent 목록 대신 라이브러리 판정) |
| seo.og_default_site_name | body | string | 아니오 | max 200 | seo.og default site 이름 (식별자) |
+| seo.og_image_default | body | array | 아니오 | 항목: 존재하는 첨부 ID | 사이트 기본 공유 이미지(og:image) 첨부 ID 배열 — 화면별 og:image 가 없을 때 폴백으로 사용. 화면 제출 형태(첨부 객체 배열)도 수용하며 정수 ID 배열로 정규화된다 (7.0.9+) |
| seo.og_image_default_width | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 너비 (px) |
| seo.og_image_default_height | body | integer | 아니오 | min 0, max 8000 | 기본 Open Graph 이미지 높이 (px) |
| seo.twitter_default_card | body | string | 아니오 | — | 기본 트위터 카드 유형 (summary 등) |
diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md
index da3f4025..8965b663 100644
--- a/docs/extension/hooks.md
+++ b/docs/extension/hooks.md
@@ -133,6 +133,14 @@ core.activity_log.before_prune core.activity_log.after_prune
core.notification_log.before_prune core.notification_log.after_prune
core.schedule.before_prune_history core.schedule.after_prune_history
+# 본문 첫 내부 이미지 썸네일 캐시 (Filter, 공개#22) — 각 모델 saving 이벤트가 발행.
+# 값 = 추출된 첫 내부 이미지 URL(없으면 null), 인자 = (값, 모델, 전체 후보 src 배열).
+# 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null 반환)할 수 있다.
+# 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
+sirsoft-board.post.filter_content_thumbnail
+sirsoft-ecommerce.product.filter_content_thumbnail
+sirsoft-page.page.filter_content_thumbnail
+
# 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴
# before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환.
# 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션)
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index ed9e34e5..e4b0904f 100644
--- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.8] - 2026-08-22
+
+### Added
+
+- 사이트 기본 공유 이미지(og:image) 설정의 검증 속성 라벨 번역을 추가했습니다.
+
## [1.0.7] - 2026-08-22
### Added
diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
index 92ca8a3c..0d6f151b 100644
--- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
+++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
@@ -1129,6 +1129,7 @@ return [
'maintenance_mode' => 'メンテナンスモード',
'asset_url_mode' => 'アセットURL方式',
'site_logo' => 'サイトロゴ',
+ 'og_image_default' => '既定の共有画像',
'bot_user_agents' => 'ボット User-Agent 一覧',
'bot_detection_enabled' => 'ボット検出の使用',
'bot_detection_library_enabled' => 'ボット検出ライブラリの使用',
diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json
index e7ad4030..cb8bc8e2 100644
--- a/lang-packs/_bundled/g7-core-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-core-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 core Japanese language pack (bundled)",
"ja": "G7 コア 日本語 言語パック(バンドル)"
},
- "version": "1.0.7",
+ "version": "1.0.8",
"license": "MIT",
"scope": "core",
"target_identifier": null,
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
index 1bc252fc..dab376af 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.7] - 2026-08-22
+
+### Added
+
+- 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다.
+
## [1.0.6] - 2026-08-22
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
index 1fcd76de..d6435cd2 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
@@ -1423,6 +1423,8 @@
"og_image_default_width": "OG 画像 デフォルト横幅 (px)",
"og_image_default_height": "OG 画像 デフォルト高さ (px)",
"og_image_default_size_hint": "サムネイルの実際のサイズが不明な場合のデフォルト値です。Facebookは推奨 1200×630、最小 200×200。",
+ "og_image_default": "既定の共有画像",
+ "og_image_default_desc": "画面ごとの共有画像がない場合に使用するサイト既定の画像です。リンク共有時のプレビューカードに表示されます。",
"twitter_default_card": "Twitterカード デフォルト タイプ",
"twitter_default_card_hint": "大きい画像のカードは summary_large_image、小さいカードは summary を推奨します。",
"twitter_default_site": "Twitter サイト ハンドル",
diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
index a69f93ef..3d15faac 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
},
- "version": "1.0.6",
+ "version": "1.0.7",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-admin_basic",
diff --git a/lang/en/validation.php b/lang/en/validation.php
index 7e8e554e..47bb432f 100644
--- a/lang/en/validation.php
+++ b/lang/en/validation.php
@@ -1272,6 +1272,7 @@ return [
'maintenance_mode' => 'maintenance mode',
'asset_url_mode' => 'asset URL mode',
'site_logo' => 'site logo',
+ 'og_image_default' => 'default share image',
// SEO settings (additional)
'bot_user_agents' => 'bot user agents',
'bot_detection_enabled' => 'bot detection',
diff --git a/lang/ko/validation.php b/lang/ko/validation.php
index 78fa94a4..b62ccec4 100644
--- a/lang/ko/validation.php
+++ b/lang/ko/validation.php
@@ -1265,6 +1265,7 @@ return [
'maintenance_mode' => '점검 모드',
'asset_url_mode' => '자산 주소 방식',
'site_logo' => '사이트 로고',
+ 'og_image_default' => '기본 공유 이미지',
// SEO 설정 (추가)
'bot_user_agents' => '봇 User-Agent 목록',
'bot_detection_enabled' => '봇 감지 사용',
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 00d1a696..0d390800 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -4,6 +4,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.0] - 2026-08-22
+
+### Added
+
+- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
+- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
+
## [1.0.5] - 2026-08-22
### Security
diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json
index 85fc5089..b426f515 100644
--- a/modules/_bundled/sirsoft-board/composer.json
+++ b/modules/_bundled/sirsoft-board/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-board",
"description": "Board module for Gnuboard7",
"type": "library",
- "version": "1.0.5",
+ "version": "1.1.0",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php b/modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php
new file mode 100644
index 00000000..fec6752a
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_board_posts_table.php
@@ -0,0 +1,43 @@
+string('content_thumbnail_url', 1000)
+ ->nullable()
+ ->after('content_mode')
+ ->comment('본문 첫 내부 이미지 URL 캐시 — 이미지 첨부가 없을 때 목록 썸네일 폴백');
+ }
+ });
+ }
+
+ /**
+ * 마이그레이션 롤백
+ */
+ public function down(): void
+ {
+ if (Schema::hasTable('board_posts')) {
+ $columns = Schema::getColumnListing('board_posts');
+
+ Schema::table('board_posts', function (Blueprint $table) use ($columns) {
+ if (in_array('content_thumbnail_url', $columns)) {
+ $table->dropColumn('content_thumbnail_url');
+ }
+ });
+ }
+ }
+};
diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md
index 1d0c2f2c..97348780 100644
--- a/modules/_bundled/sirsoft-board/docs/api/board.md
+++ b/modules/_bundled/sirsoft-board/docs/api/board.md
@@ -361,7 +361,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -529,7 +529,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 게시글 수정(PUT
| reply_count | integer | `0` | 답변글 수 (집계) |
| attachment_count | integer | `0` | 첨부파일 수 (집계) |
| has_attachment | boolean | `false` | 첨부파일 보유 여부 |
-| thumbnail | null | `null` | 썸네일 이미지 URL/경로 (없으면 null) |
+| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위) |
| is_reply | boolean | `false` | 답변글 여부 |
@@ -880,7 +880,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
+| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -1040,7 +1040,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시 노출 차단 — 필드 자체는 유지) |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL). 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` 로 내려간다(첨부 해시·본문 이미지 URL 노출 차단 — 필드 자체는 유지) |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -1300,7 +1300,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
+| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -1459,7 +1459,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
+| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -1625,7 +1625,7 @@ _단건 응답: `data` 객체의 필드 (`PostResource` — 복원된 게시글.
| reply_count | integer | `0` | 답변글 수 (집계) |
| attachment_count | integer | `0` | 첨부파일 수 (집계) |
| has_attachment | boolean | `false` | 첨부파일 보유 여부 |
-| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
+| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | 원글 ID (답변글일 때만 값 존재) |
| depth | integer | `0` | 계층 트리에서의 깊이 |
| is_reply | boolean | `false` | 답변글 여부 |
diff --git a/modules/_bundled/sirsoft-board/docs/api/boards.md b/modules/_bundled/sirsoft-board/docs/api/boards.md
index a964edb6..113e8727 100644
--- a/modules/_bundled/sirsoft-board/docs/api/boards.md
+++ b/modules/_bundled/sirsoft-board/docs/api/boards.md
@@ -2848,7 +2848,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -3020,7 +3020,7 @@ _단건 응답: `data` 객체의 필드 (생성된 게시글 = `PostResource`,
| reply_count | integer | `0` | 답변글 수 (집계) |
| attachment_count | integer | `0` | 첨부 수 (집계) |
| has_attachment | boolean | `false` | 첨부 보유 여부 |
-| thumbnail | null | `null` | 썸네일 이미지 URL (첫 이미지 첨부 — 없으면 null) |
+| thumbnail | null | `null` | 썸네일 이미지 URL — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | 답변글의 원글 식별자 (원글이면 null) |
| depth | integer | `0` | 계층 트리 깊이 (0 = 원글) |
| is_reply | boolean | `false` | 답변글 여부 |
@@ -3438,7 +3438,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 |
+| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
@@ -3696,7 +3696,7 @@ _단건 응답: `data` 객체의 필드._
| reply_count | integer | `0` | reply 개수 (집계) |
| attachment_count | integer | `0` | attachment 개수 (집계) |
| has_attachment | boolean | `false` | attachment 여부 |
-| thumbnail | null | `null` | 썸네일 이미지 URL/경로 |
+| thumbnail | null | `null` | 썸네일 이미지 URL/경로 — 첫 이미지 첨부의 미리보기 서빙 URL. 이미지 첨부가 없으면 본문 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.1.0+). 비밀글은 열람 권한이 없으면 `null` |
| parent_id | null | `null` | parent 식별자 (연관 리소스 참조) |
| depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) |
| is_reply | boolean | `false` | reply 여부 |
diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json
index 95d8b194..e2fa63ac 100644
--- a/modules/_bundled/sirsoft-board/module.json
+++ b/modules/_bundled/sirsoft-board/module.json
@@ -5,13 +5,13 @@
"ko": "게시판",
"en": "Board"
},
- "version": "1.0.5",
+ "version": "1.1.0",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
- "g7_version": ">=7.0.7",
+ "g7_version": ">=7.0.9",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json
index ba6f089e..4b0d6142 100644
--- a/modules/_bundled/sirsoft-board/package-lock.json
+++ b/modules/_bundled/sirsoft-board/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.5",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-board",
- "version": "1.0.5",
+ "version": "1.1.0",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json
index 44254293..ebf99bd7 100644
--- a/modules/_bundled/sirsoft-board/package.json
+++ b/modules/_bundled/sirsoft-board/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-board",
- "version": "1.0.5",
+ "version": "1.1.0",
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
index c7951c3c..892f38e5 100644
--- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
+++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php
@@ -303,7 +303,8 @@ class PostResource extends BaseApiResource
* 썸네일 URL을 관계 로딩 상태에 따라 반환합니다.
*
* thumbnailAttachment(경량 hasOne) 우선, attachments(전체) fallback.
- * 둘 다 미로딩 시 null 반환 (lazy loading 방지).
+ * 첨부에서 URL 을 얻지 못한 모든 경우 본문 첫 내부 이미지 캐시
+ * (content_thumbnail_url)로 폴백하고, 그것도 없으면 null (공개 이슈 #22).
*
* @return string|null 썸네일 URL
*/
@@ -312,28 +313,29 @@ class PostResource extends BaseApiResource
// 비밀글은 썸네일 URL 자체를 방출하지 않는다 — 서빙은 이미 차단되어 이미지가 보이지는
// 않지만, URL 에 실린 첨부 해시가 목록·상세 응답으로 나가 있었다(KVE-2026-1894).
// 판정은 첨부 목록과 같은 SecretContentGate(SSoT)를 쓴다. 필드는 남기고 값만 가린다.
+ // 본문 캐시 폴백도 반드시 이 게이트 뒤 — 에디터 이미지는 공개 hash 서빙이라
+ // 첨부와 달리 서빙측 차단이 없어, 이 게이트가 유일한 차단선이다.
if ($this->is_secret && ! $this->canViewSecretContent(request())) {
return null;
}
+ $url = null;
+
// 목록용 경량 관계 우선 — slug를 직접 전달하여 Board::find() N+1 방지
if ($this->relationLoaded('thumbnailAttachment') && $this->thumbnailAttachment) {
$attachment = $this->thumbnailAttachment;
$slug = request()->route('slug') ?? ($this->relationLoaded('board') ? $this->board?->slug : null);
if ($slug && $attachment->hash) {
- return $attachment->previewUrlForSlug($slug);
+ $url = $attachment->previewUrlForSlug($slug);
}
-
- return null;
+ } elseif ($this->relationLoaded('attachments')) {
+ // 상세 페이지 등에서 attachments가 로딩된 경우 fallback
+ $url = $this->getThumbnailUrl();
}
- // 상세 페이지 등에서 attachments가 로딩된 경우 fallback
- if ($this->relationLoaded('attachments')) {
- return $this->getThumbnailUrl();
- }
-
- return null;
+ // 이미지 첨부가 없을 때만 본문 첫 내부 이미지 캐시 폴백 (첨부 우선 정책)
+ return $url ?? ($this->content_thumbnail_url ?: null);
}
/**
diff --git a/modules/_bundled/sirsoft-board/src/Models/Post.php b/modules/_bundled/sirsoft-board/src/Models/Post.php
index 483f88f1..0887c563 100644
--- a/modules/_bundled/sirsoft-board/src/Models/Post.php
+++ b/modules/_bundled/sirsoft-board/src/Models/Post.php
@@ -5,10 +5,12 @@ namespace Modules\Sirsoft\Board\Models;
use App\Extension\HookManager;
use App\Models\User;
use App\Search\Contracts\FulltextSearchable;
+use App\Support\HtmlImageExtractor;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
+use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Laravel\Scout\Searchable;
use Modules\Sirsoft\Board\Enums\PostStatus;
@@ -30,7 +32,7 @@ class Post extends Model implements FulltextSearchable
'content_mode' => ['label_key' => 'sirsoft-board::activity_log.fields.content_mode', 'type' => 'text'],
'is_notice' => ['label_key' => 'sirsoft-board::activity_log.fields.is_notice', 'type' => 'boolean'],
'is_secret' => ['label_key' => 'sirsoft-board::activity_log.fields.is_secret', 'type' => 'boolean'],
- 'status' => ['label_key' => 'sirsoft-board::activity_log.fields.status', 'type' => 'enum', 'enum' => \Modules\Sirsoft\Board\Enums\PostStatus::class],
+ 'status' => ['label_key' => 'sirsoft-board::activity_log.fields.status', 'type' => 'enum', 'enum' => PostStatus::class],
];
/**
@@ -64,6 +66,7 @@ class Post extends Model implements FulltextSearchable
'title',
'content',
'content_mode',
+ 'content_thumbnail_url',
'user_id',
'author_name',
'password',
@@ -105,6 +108,47 @@ class Post extends Model implements FulltextSearchable
];
}
+ /**
+ * 모델 이벤트 등록
+ *
+ * 본문 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다 —
+ * 목록 쿼리는 content 를 SELECT 하지 않으므로(컬럼 프루닝) 조회 시점 파싱이 불가하다.
+ * Service 가 아닌 모델 saving 이벤트에 두는 이유: 시더·테스트의 Post::create() 직접
+ * 호출과 이커머스 문의 훅 리스너 경유 경로까지 누락 없이 커버되는 유일 지점이기 때문.
+ */
+ protected static function booted(): void
+ {
+ static::saving(function (Post $post) {
+ if ($post->exists && ! $post->isDirty('content') && ! $post->isDirty('content_mode')) {
+ return;
+ }
+
+ // text 모드 본문은 이스케이프되어 렌더된다(상세에 이미지 미표시) — 리터럴
+ // img 마크업을 캐시하면 목록 썸네일만 떠서 상세와 어긋나므로 추출하지 않는다.
+ if ($post->content_mode !== 'html') {
+ $post->content_thumbnail_url = null;
+
+ return;
+ }
+
+ $content = (string) $post->content;
+
+ // 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅.
+ // 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
+ $value = HookManager::applyFilters(
+ 'sirsoft-board.post.filter_content_thumbnail',
+ HtmlImageExtractor::firstInternal($content),
+ $post,
+ HtmlImageExtractor::candidates($content)
+ );
+
+ // 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등
+ $post->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000
+ ? $value
+ : null;
+ });
+ }
+
/**
* 게시판과의 관계를 정의합니다.
*
@@ -181,9 +225,9 @@ class Post extends Model implements FulltextSearchable
*
* 전체 attachments를 eager loading하는 대신 이미지 1건만 가져와 성능을 확보합니다.
*
- * @return \Illuminate\Database\Eloquent\Relations\HasOne
+ * @return HasOne
*/
- public function thumbnailAttachment(): \Illuminate\Database\Eloquent\Relations\HasOne
+ public function thumbnailAttachment(): HasOne
{
return $this->hasOne(Attachment::class, 'post_id')
->where('mime_type', 'like', 'image/%')
diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
index b4213362..cfc9d43b 100644
--- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
+++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php
@@ -62,7 +62,7 @@ class PostRepository implements PostRepositoryInterface
// 목록 전용 컬럼: content(본문 HTML) 제외 → content_preview로 대체
$listColumns = [
'id', 'board_id', 'user_id', 'parent_id', 'category',
- 'title', 'author_name', 'content_mode',
+ 'title', 'author_name', 'content_mode', 'content_thumbnail_url',
'is_notice', 'is_secret', 'status', 'depth',
'view_count', 'comments_count', 'replies_count', 'attachments_count',
'trigger_type', 'ip_address', 'created_at', 'updated_at', 'deleted_at',
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php
new file mode 100644
index 00000000..31b39878
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php
@@ -0,0 +1,169 @@
+run($context);
+ }
+
+ /**
+ * @scenario backfill=legacy_row
+ *
+ * @effects backfill_fills_legacy_rows
+ */
+ #[Test]
+ public function legacy_rows_are_backfilled_with_first_internal_image(): void
+ {
+ // createTestPost 는 DB 직접 insert — 모델 saving 이벤트를 타지 않아
+ // 업그레이드 전 작성 글(캐시 null)과 동일한 상태다
+ $legacy = $this->createTestPost([
+ 'content_mode' => 'html',
+ 'content' => '글

',
+ ]);
+
+ $this->runBackfill();
+
+ $this->assertSame(
+ '/storage/uploads/legacy.jpg',
+ DB::table('board_posts')->where('id', $legacy)->value('content_thumbnail_url'),
+ '첫 외부 후보는 건너뛰고 첫 내부 이미지가 백필되어야 합니다.'
+ );
+ }
+
+ /**
+ * @scenario backfill=already_filled
+ *
+ * @effects backfill_fills_legacy_rows
+ */
+ #[Test]
+ public function already_filled_rows_are_untouched(): void
+ {
+ $filled = $this->createTestPost([
+ 'content' => '
',
+ 'content_thumbnail_url' => '/storage/uploads/kept.jpg',
+ ]);
+
+ $this->runBackfill();
+
+ $this->assertSame(
+ '/storage/uploads/kept.jpg',
+ DB::table('board_posts')->where('id', $filled)->value('content_thumbnail_url'),
+ '이미 값이 있는 행은 백필 대상이 아니어야 합니다.'
+ );
+ }
+
+ /**
+ * @scenario backfill=external_only
+ *
+ * @effects backfill_keeps_external_only_null
+ */
+ #[Test]
+ public function external_only_rows_stay_null(): void
+ {
+ $external = $this->createTestPost([
+ 'content_mode' => 'html',
+ 'content' => '
',
+ ]);
+ $noImage = $this->createTestPost(['content_mode' => 'html', 'content' => '이미지 없음
']);
+
+ $this->runBackfill();
+
+ $this->assertNull(DB::table('board_posts')->where('id', $external)->value('content_thumbnail_url'));
+ $this->assertNull(DB::table('board_posts')->where('id', $noImage)->value('content_thumbnail_url'));
+ }
+
+ /**
+ * text 모드 레거시 행은 리터럴 img 마크업이 있어도 백필되지 않아야 합니다.
+ *
+ * text 모드 본문은 이스케이프 렌더라 상세에 이미지가 표시되지 않는다 —
+ * 목록 썸네일만 뜨면 상세와 어긋난다 (content_mode 기본값이 text 라 모집단이 크다).
+ *
+ * @scenario backfill=text_mode
+ *
+ * @effects text_mode_content_never_caches
+ */
+ #[Test]
+ public function text_mode_rows_are_not_backfilled(): void
+ {
+ $textMode = $this->createTestPost([
+ 'content_mode' => 'text',
+ 'content' => '텍스트 본문의
마크업',
+ ]);
+
+ $this->runBackfill();
+
+ $this->assertNull(
+ DB::table('board_posts')->where('id', $textMode)->value('content_thumbnail_url'),
+ 'text 모드 글은 백필 대상이 아니어야 합니다.'
+ );
+ }
+
+ /**
+ * @scenario backfill=idempotent
+ *
+ * @effects backfill_idempotent_second_run
+ */
+ #[Test]
+ public function second_run_is_idempotent(): void
+ {
+ $this->createTestPost(['content_mode' => 'html', 'content' => '
']);
+ $this->createTestPost(['content_mode' => 'html', 'content' => '
']);
+ $this->createTestPost(['content_mode' => 'html', 'content' => '이미지 없음
']);
+
+ $this->runBackfill();
+ $first = $this->snapshot();
+
+ $this->runBackfill();
+ $second = $this->snapshot();
+
+ $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
+ }
+
+ /**
+ * 이 게시판 게시글의 (id, content_thumbnail_url) 스냅샷을 반환합니다.
+ *
+ * @return array 스냅샷
+ */
+ private function snapshot(): array
+ {
+ return DB::table('board_posts')
+ ->where('board_id', $this->board->id)
+ ->orderBy('id')
+ ->get(['id', 'content_thumbnail_url'])
+ ->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url])
+ ->all();
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php b/modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php
new file mode 100644
index 00000000..eee904bd
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php
@@ -0,0 +1,264 @@
+ $slug,
+ 'name' => ['ko' => '본문 썸네일 게시판', 'en' => 'Content Thumbnail Board'],
+ 'is_active' => true,
+ 'secret_mode' => 'enabled',
+ ];
+ }
+
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->grantUserRolePermissions();
+
+ $this->memberUser = User::factory()->create(['email' => 'thumb-member@test.com']);
+ $userRole = Role::where('identifier', 'user')->first();
+ $this->memberUser->roles()->attach($userRole->id);
+ }
+
+ /**
+ * 모델 이벤트를 경유해 게시글을 생성합니다 (saving 추출 경로 포함).
+ *
+ * @param array $attributes 덮어쓸 속성
+ * @return Post 생성된 게시글
+ */
+ private function createPostViaModel(array $attributes = []): Post
+ {
+ return Post::create(array_merge([
+ 'board_id' => $this->board->id,
+ 'title' => '본문 썸네일 글',
+ 'content' => '본문
',
+ 'content_mode' => 'html',
+ 'author_name' => '테스트',
+ 'ip_address' => '127.0.0.1',
+ 'is_notice' => false,
+ 'is_secret' => false,
+ 'status' => 'published',
+ 'trigger_type' => 'admin',
+ ], $attributes));
+ }
+
+ /**
+ * 목록 응답에서 특정 게시글 행을 찾습니다.
+ *
+ * @param int $postId 게시글 ID
+ * @return array|null 목록 행
+ */
+ private function findListItem(int $postId): ?array
+ {
+ $response = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts");
+ $response->assertStatus(200);
+
+ foreach ($response->json('data.data') ?? [] as $item) {
+ if (($item['id'] ?? null) === $postId) {
+ return $item;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, secrecy=normal
+ *
+ * @effects content_internal_image_fills_list_thumbnail
+ */
+ #[Test]
+ public function list_thumbnail_filled_from_content_image_without_attachment(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '글
',
+ ]);
+
+ $item = $this->findListItem($post->id);
+
+ $this->assertNotNull($item, '목록에 게시글이 있어야 합니다.');
+ $this->assertSame('/storage/uploads/list-content.jpg', $item['thumbnail']);
+ }
+
+ /**
+ * @scenario image_source=both, secrecy=normal
+ *
+ * @effects attachment_takes_precedence_over_content_image
+ */
+ #[Test]
+ public function list_thumbnail_prefers_attachment_over_content_image(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $attachment = Attachment::create([
+ 'board_id' => $this->board->id,
+ 'post_id' => $post->id,
+ 'original_filename' => 'attach.jpg',
+ 'stored_filename' => 'stored-attach.jpg',
+ 'disk' => 'modules',
+ 'path' => 'attachments/attach.jpg',
+ 'mime_type' => 'image/jpeg',
+ 'size' => 100,
+ 'collection' => 'attachments',
+ 'order' => 0,
+ ]);
+
+ $item = $this->findListItem($post->id);
+
+ $this->assertNotNull($item);
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
+ $item['thumbnail'],
+ '이미지 첨부가 있으면 본문 캐시보다 첨부가 우선해야 합니다.'
+ );
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, secrecy=secret_unauthorized
+ *
+ * @effects secret_post_thumbnail_stays_null
+ */
+ #[Test]
+ public function secret_post_list_thumbnail_is_null_for_guest(): void
+ {
+ $post = $this->createPostViaModel([
+ 'is_secret' => true,
+ 'content' => '
',
+ ]);
+
+ $item = $this->findListItem($post->id);
+
+ $this->assertNotNull($item, '비밀글도 목록 행 자체는 노출됩니다 (제목/배지).');
+ $this->assertNull($item['thumbnail'], '권한 없는 조회에서 본문 캐시 URL 이 노출되면 안 됩니다.');
+
+ // 응답 어디에도 이미지 URL 이 실리지 않아야 한다 (에디터 이미지는 공개 서빙)
+ $raw = $this->getJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts")->getContent();
+ $this->assertStringNotContainsString('secret-content.jpg', $raw);
+ }
+
+ /**
+ * @scenario image_source=content_external_only, secrecy=normal
+ *
+ * @effects external_only_content_yields_null
+ */
+ #[Test]
+ public function external_only_content_yields_null_thumbnail_in_list(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $item = $this->findListItem($post->id);
+
+ $this->assertNotNull($item);
+ $this->assertNull($item['thumbnail']);
+ }
+
+ /**
+ * @scenario image_source=attachment_only, secrecy=normal
+ *
+ * @effects attachment_takes_precedence_over_content_image
+ */
+ #[Test]
+ public function attachment_only_post_keeps_existing_behavior(): void
+ {
+ $post = $this->createPostViaModel(['content' => '첨부만 있는 글
']);
+
+ $attachment = Attachment::create([
+ 'board_id' => $this->board->id,
+ 'post_id' => $post->id,
+ 'original_filename' => 'only.jpg',
+ 'stored_filename' => 'stored-only.jpg',
+ 'disk' => 'modules',
+ 'path' => 'attachments/only.jpg',
+ 'mime_type' => 'image/jpeg',
+ 'size' => 100,
+ 'collection' => 'attachments',
+ 'order' => 0,
+ ]);
+
+ $item = $this->findListItem($post->id);
+
+ $this->assertNotNull($item);
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
+ $item['thumbnail'],
+ );
+ }
+
+ /**
+ * 생성 응답의 thumbnail 이 본문 이미지로 즉시 채워져야 합니다.
+ *
+ * @effects detail_and_og_share_same_fallback
+ */
+ #[Test]
+ public function create_response_thumbnail_is_immediately_filled(): void
+ {
+ $response = $this->actingAs($this->memberUser)
+ ->postJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts", [
+ 'title' => '에디터 이미지 글',
+ 'content' => '본문
',
+ 'content_mode' => 'html',
+ 'author_name' => '회원작성자',
+ ]);
+
+ $response->assertStatus(201);
+ $this->assertSame('/storage/uploads/created.jpg', $response->json('data.thumbnail'));
+ }
+
+ /**
+ * 수정 응답의 thumbnail 이 교체된 본문 이미지로 즉시 갱신되어야 합니다.
+ *
+ * @effects detail_and_og_share_same_fallback
+ */
+ #[Test]
+ public function update_response_thumbnail_reflects_new_content(): void
+ {
+ $post = $this->createPostViaModel([
+ 'user_id' => $this->memberUser->id,
+ 'trigger_type' => 'user',
+ 'content' => '
',
+ ]);
+
+ $response = $this->actingAs($this->memberUser)
+ ->putJson("/api/modules/sirsoft-board/boards/{$this->board->slug}/posts/{$post->id}", [
+ 'title' => '수정된 글',
+ 'content' => '
',
+ 'content_mode' => 'html',
+ ]);
+
+ $response->assertStatus(200);
+ $this->assertSame('/storage/uploads/after.jpg', $response->json('data.thumbnail'));
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts
index 733e0686..a816dc5e 100644
--- a/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts
+++ b/modules/_bundled/sirsoft-board/tests/Playwright/fixtures/board-auth.ts
@@ -29,6 +29,8 @@ type BoardAuthFixtures = {
boardManageToken: string;
/** 게시판 조회 + 첨부 다운로드 권한 토큰 (#413-58b 행위자 기록 검증용) */
attachmentDownloadToken: string;
+ /** 본문 썸네일 폴백 E2E 용 — 게시판 CRUD + 전용 게시판(e2e-content-thumbnail) 글/첨부 관리 토큰 (공개 #22) */
+ contentThumbnailToken: string;
};
export const test = base.extend({
@@ -89,6 +91,22 @@ export const test = base.extend({
),
);
},
+ contentThumbnailToken: async ({}, use) => {
+ // 전용 카드형 게시판(e2e-content-thumbnail)을 spec 안에서 생성/삭제하고,
+ // 관리자 글 작성 + 첨부 업로드로 첨부 우선 정책까지 실측한다 (공개 #22).
+ await use(
+ issueToken(
+ 'sirsoft-board.boards.read',
+ 'sirsoft-board.boards.create',
+ 'sirsoft-board.boards.update',
+ 'sirsoft-board.boards.delete',
+ 'sirsoft-board.e2e-content-thumbnail.admin.posts.read',
+ 'sirsoft-board.e2e-content-thumbnail.admin.posts.write',
+ 'sirsoft-board.e2e-content-thumbnail.admin.manage',
+ 'sirsoft-board.e2e-content-thumbnail.admin.attachments.upload',
+ ),
+ );
+ },
});
export { authenticatePage };
diff --git a/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts
new file mode 100644
index 00000000..efb93ff8
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts
@@ -0,0 +1,241 @@
+/**
+ * 카드형 목록 — 본문 첫 내부 이미지 썸네일 폴백 (공개 이슈 #22, 내부 #610).
+ *
+ * 에디터로 본문에만 이미지를 넣은 글(첨부 없음)이 카드형 목록에서 "이미지 없음"
+ * placeholder 대신 실제 썸네일을 렌더하는지, 이미지 첨부가 있으면 종전대로 첨부가
+ * 우선하는지를 브라우저 수준에서 실측한다. 기대 이미지 URL 은 상수로 박제하지 않고
+ * 그 글 상세 API 의 thumbnail 값을 읽어 단언한다 (운영자 편집 값 박제 금지 규율).
+ *
+ * 단위/통합: PostContentThumbnailTest(saving 추출), PostResourceThumbnailUrlTest(폴백
+ * 우선순위·비밀글 게이트), PostListContentThumbnailTest(목록 API 계약)가 담당하고,
+ * 이 spec 은 카드 레이아웃 렌더(브라우저)를 담당한다.
+ *
+ * 전용 게시판(e2e-content-thumbnail)을 spec 안에서 생성/삭제하므로 시드 의존이 없다.
+ *
+ * @scenario board-content-thumbnail-fallback
+ * @axes image_source=content_internal_only image_source=both image_source=none board_type=card
+ * @effects content_internal_image_fills_list_thumbnail,
+ * attachment_takes_precedence_over_content_image,
+ * detail_and_og_share_same_fallback
+ */
+import { test, expect, authenticatePage } from '../../fixtures/board-auth';
+
+const SLUG = 'e2e-content-thumbnail';
+const API = '/api/modules/sirsoft-board';
+
+/** 브라우저 컨텍스트에서 Bearer 토큰으로 API 를 호출한다 (실 브라우저 실측 규약). */
+async function api(
+ page: import('@playwright/test').Page,
+ bearer: string,
+ method: string,
+ path: string,
+ body?: unknown,
+): Promise<{ status: number; body: any }> {
+ return page.evaluate(
+ async ({ bearer, method, path, body }) => {
+ const response = await fetch(path, {
+ method,
+ headers: {
+ Authorization: `Bearer ${bearer}`,
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ let parsed: unknown = null;
+ try {
+ parsed = await response.json();
+ } catch {
+ /* 비 JSON 응답 허용 */
+ }
+ return { status: response.status, body: parsed as any };
+ },
+ { bearer, method, path, body },
+ );
+}
+
+async function deleteBoardIfExists(page: import('@playwright/test').Page, bearer: string): Promise {
+ await api(page, bearer, 'DELETE', `${API}/admin/boards/${SLUG}`);
+}
+
+/** 전용 카드형 게시판을 생성한다 (StoreBoardRequest 필수값 포함). */
+async function createCardBoard(page: import('@playwright/test').Page, bearer: string): Promise<{ status: number; body: any }> {
+ // board_manager_ids 필수값: 시드 게시판(free)의 관리자 uuid 를 재사용한다
+ const freeBoard = await api(page, bearer, 'GET', `${API}/admin/boards/free`);
+ const managerUuid = (freeBoard.body?.data?.board_manager_ids ?? [])[0];
+
+ if (!managerUuid) {
+ throw new Error(`manager uuid unavailable: status=${freeBoard.status} body=${JSON.stringify(freeBoard.body).slice(0, 300)}`);
+ }
+
+ return api(page, bearer, 'POST', `${API}/admin/boards`, {
+ slug: SLUG,
+ name: { ko: '[검수용] 본문썸네일', en: 'Content Thumbnail E2E' },
+ type: 'card',
+ show_view_count: true,
+ use_report: false,
+ board_manager_ids: [managerUuid],
+ });
+}
+
+/** 관리자 글 작성 API 로 게시글을 생성하고 id 를 돌려준다 (모델 saving 추출 경로 경유). */
+async function createPost(
+ page: import('@playwright/test').Page,
+ bearer: string,
+ title: string,
+ content: string,
+): Promise {
+ const created = await api(page, bearer, 'POST', `${API}/admin/board/${SLUG}/posts`, {
+ title,
+ content,
+ // text 모드는 이스케이프 렌더라 추출 게이트에 걸린다 — 에디터 시나리오는 html 모드
+ content_mode: 'html',
+ author_name: 'E2E',
+ });
+ if (![200, 201].includes(created.status)) {
+ throw new Error(`post create failed: ${created.status} ${JSON.stringify(created.body).slice(0, 300)}`);
+ }
+ return created.body?.data?.id;
+}
+
+/** 1×1 픽셀 JPEG 를 브라우저에서 만들어 첨부로 업로드한다 (multipart). */
+async function uploadImageAttachment(
+ page: import('@playwright/test').Page,
+ bearer: string,
+ postId: number,
+): Promise<{ status: number; body: any }> {
+ return page.evaluate(
+ async ({ bearer, postId, apiBase, slug }) => {
+ // canvas 로 진짜 JPEG 바이트를 생성 (mimes 검증 통과)
+ const canvas = document.createElement('canvas');
+ canvas.width = 8;
+ canvas.height = 8;
+ const ctx = canvas.getContext('2d')!;
+ ctx.fillStyle = '#3b82f6';
+ ctx.fillRect(0, 0, 8, 8);
+ const blob: Blob = await new Promise((resolve) =>
+ canvas.toBlob((b) => resolve(b!), 'image/jpeg', 0.9),
+ );
+
+ const form = new FormData();
+ form.append('file', new File([blob], 'e2e-attach.jpg', { type: 'image/jpeg' }));
+ form.append('post_id', String(postId));
+
+ const response = await fetch(`${apiBase}/admin/board/${slug}/attachments`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
+ body: form,
+ });
+ let parsed: unknown = null;
+ try {
+ parsed = await response.json();
+ } catch {
+ /* 비 JSON 응답 허용 */
+ }
+ return { status: response.status, body: parsed as any };
+ },
+ { bearer, postId, apiBase: API, slug: SLUG },
+ );
+}
+
+/** 사용자 상세 API 에서 thumbnail 값을 읽는다 (기대값의 SSoT — 상수 박제 금지). */
+async function fetchDetailThumbnail(
+ page: import('@playwright/test').Page,
+ bearer: string,
+ postId: number,
+): Promise {
+ const detail = await api(page, bearer, 'GET', `${API}/boards/${SLUG}/posts/${postId}`);
+ expect(detail.status).toBe(200);
+ return detail.body?.data?.thumbnail ?? null;
+}
+
+// 전용 게시판 slug 를 생성/삭제하므로 병렬 실행 시 경합한다 — 직렬 고정
+test.describe.configure({ mode: 'serial' });
+
+test.describe('카드형 목록 본문 썸네일 폴백 (공개 #22)', () => {
+ // @scenario image_source=content_internal_only board_type=card
+ // @effects content_internal_image_fills_list_thumbnail, detail_and_og_share_same_fallback
+ test('첨부 없는 본문이미지 글의 카드에 썸네일이 렌더되고 placeholder 가 없다', async ({ page, contentThumbnailToken }) => {
+ await authenticatePage(page, contentThumbnailToken);
+ await page.goto('/');
+ await deleteBoardIfExists(page, contentThumbnailToken);
+
+ const created = await createCardBoard(page, contentThumbnailToken);
+ expect([200, 201], `board create failed: ${JSON.stringify(created.body)}`).toContain(created.status);
+
+ // 본문에만 내부 이미지 — 첨부 없음 (favicon 은 항상 서빙되는 내부 자산)
+ const postId = await createPost(
+ page,
+ contentThumbnailToken,
+ '[검수용] 본문 이미지만 있는 글',
+ '본문입니다.
',
+ );
+
+ // 기대값은 상세 API 의 thumbnail (상수 박제 금지)
+ const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId);
+ expect(thumbnail, '상세 API thumbnail 이 본문 이미지로 채워져야 합니다').toBe('/favicon.ico');
+
+ // 카드형 목록 렌더 실측
+ await page.goto(`/board/${SLUG}`);
+ await page.waitForLoadState('networkidle', { timeout: 30_000 });
+
+ const card = page.locator('a, div').filter({ hasText: '[검수용] 본문 이미지만 있는 글' }).last();
+ await expect(page.locator(`img[src="${thumbnail}"]`).first()).toBeVisible({ timeout: 15_000 });
+
+ // 그 카드에는 "이미지 없음" placeholder 가 없어야 한다
+ await expect(card.getByText('이미지 없음')).toHaveCount(0);
+ });
+
+ // @scenario image_source=both board_type=card
+ // @effects attachment_takes_precedence_over_content_image
+ test('첨부와 본문이미지가 함께 있으면 첨부 preview 가 우선한다', async ({ page, contentThumbnailToken }) => {
+ await authenticatePage(page, contentThumbnailToken);
+ await page.goto('/');
+
+ // 본문 이미지 + 이미지 첨부 동시 보유 글
+ const postId = await createPost(
+ page,
+ contentThumbnailToken,
+ '[검수용] 첨부 우선 글',
+ '본문입니다.
',
+ );
+ const uploaded = await uploadImageAttachment(page, contentThumbnailToken, postId);
+ expect([200, 201], `attachment upload failed: ${JSON.stringify(uploaded.body).slice(0, 300)}`).toContain(uploaded.status);
+
+ const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId);
+ expect(thumbnail, '첨부가 있으면 첨부 preview URL 이어야 합니다').toContain('/attachment/');
+ expect(thumbnail).toContain('/preview');
+
+ await page.goto(`/board/${SLUG}`);
+ await page.waitForLoadState('networkidle', { timeout: 30_000 });
+
+ // 카드에 첨부 preview 가 렌더된다 (본문 favicon 이 아니라)
+ await expect(page.locator(`img[src="${thumbnail}"]`).first()).toBeVisible({ timeout: 15_000 });
+ });
+
+ // @scenario image_source=none board_type=card
+ // @effects content_internal_image_fills_list_thumbnail
+ test('이미지가 전혀 없는 글은 placeholder 가 유지된다 (회귀 없음) — 정리 포함', async ({ page, contentThumbnailToken }) => {
+ await authenticatePage(page, contentThumbnailToken);
+ await page.goto('/');
+
+ const postId = await createPost(
+ page,
+ contentThumbnailToken,
+ '[검수용] 이미지 없는 글',
+ '이미지 없이 텍스트만 있는 본문입니다.
',
+ );
+
+ const thumbnail = await fetchDetailThumbnail(page, contentThumbnailToken, postId);
+ expect(thumbnail).toBeNull();
+
+ await page.goto(`/board/${SLUG}`);
+ await page.waitForLoadState('networkidle', { timeout: 30_000 });
+
+ // 이미지 없는 글의 카드에는 placeholder("이미지 없음")가 렌더된다
+ await expect(page.getByText('이미지 없음').first()).toBeVisible({ timeout: 15_000 });
+
+ // 생성물 정리 — 전용 게시판 삭제 (글·첨부 포함)
+ await deleteBoardIfExists(page, contentThumbnailToken);
+ });
+});
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php
new file mode 100644
index 00000000..877a199a
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php
@@ -0,0 +1,258 @@
+ $this->board->id,
+ 'title' => '본문 썸네일 테스트',
+ 'content' => '본문
',
+ 'content_mode' => 'html',
+ 'author_name' => '테스트',
+ 'ip_address' => '127.0.0.1',
+ 'is_notice' => false,
+ 'is_secret' => false,
+ 'status' => 'published',
+ 'trigger_type' => 'admin',
+ ], $attributes));
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, secrecy=normal
+ *
+ * @effects content_internal_image_fills_list_thumbnail
+ */
+ #[Test]
+ public function saving_extracts_first_internal_image_on_create(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '글

',
+ ]);
+
+ $this->assertSame('/storage/uploads/first.jpg', $post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @scenario image_source=content_external_only, secrecy=normal
+ *
+ * @effects external_only_content_yields_null
+ */
+ #[Test]
+ public function external_only_content_yields_null(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @scenario image_source=none, secrecy=normal
+ *
+ * @effects external_only_content_yields_null
+ */
+ #[Test]
+ public function content_without_image_yields_null(): void
+ {
+ $post = $this->createPostViaModel(['content' => '이미지가 없는 본문
']);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @effects recompute_on_content_change
+ */
+ #[Test]
+ public function content_change_recomputes_cache(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $post->update(['content' => '
']);
+
+ $this->assertSame('/storage/uploads/new.jpg', $post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @effects recompute_on_content_change
+ */
+ #[Test]
+ public function removing_all_images_resets_cache_to_null(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $post->update(['content' => '이미지 전부 제거됨
']);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @effects no_recompute_on_unrelated_update
+ */
+ #[Test]
+ public function unrelated_update_does_not_recompute(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ // 이벤트 우회로 캐시를 다른 값으로 바꿔 두면, content 무변경 저장이
+ // 재계산을 하지 않는다는 사실이 값 보존으로 관측된다
+ DB::table('board_posts')->where('id', $post->id)
+ ->update(['content_thumbnail_url' => '/storage/uploads/manual.jpg']);
+
+ $post->fresh()->update(['title' => '제목만 변경']);
+
+ $this->assertSame(
+ '/storage/uploads/manual.jpg',
+ $post->fresh()->content_thumbnail_url,
+ 'content 가 dirty 가 아니면 캐시를 재계산하지 않아야 합니다.'
+ );
+ }
+
+ /**
+ * @effects filter_hook_can_override_or_block
+ */
+ #[Test]
+ public function filter_hook_can_override_value(): void
+ {
+ HookManager::addFilter(self::FILTER_HOOK, function ($value, $post, $candidates) {
+ $this->assertIsArray($candidates, '세 번째 인자로 전체 후보 목록이 전달되어야 합니다.');
+
+ return 'https://cdn.example.net/promoted.jpg';
+ });
+
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $this->assertSame('https://cdn.example.net/promoted.jpg', $post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @effects filter_hook_can_override_or_block
+ */
+ #[Test]
+ public function filter_hook_can_block_fallback(): void
+ {
+ HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null);
+
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * text 모드 글은 리터럴 img 마크업이 있어도 캐시하지 않아야 합니다.
+ *
+ * text 모드 본문은 이스케이프되어 렌더되므로(상세에 이미지가 표시되지 않음)
+ * 목록 썸네일만 뜨면 상세와 목록이 어긋난다 (Chrome MCP T10② 실측 회귀).
+ *
+ * @effects text_mode_content_never_caches
+ */
+ #[Test]
+ public function text_mode_content_is_never_cached(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content_mode' => 'text',
+ 'content' => '텍스트 본문에 쓴
마크업',
+ ]);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * content_mode 를 html → text 로 바꾸면 캐시가 비워져야 합니다 (content 무변경이어도).
+ *
+ * @effects mode_switch_recomputes_cache
+ */
+ #[Test]
+ public function switching_to_text_mode_clears_cache(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $this->assertSame('/storage/uploads/a.jpg', $post->fresh()->content_thumbnail_url);
+
+ $post->fresh()->update(['content_mode' => 'text']);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url, 'text 모드 전환 시 캐시가 비워져야 합니다.');
+ }
+
+ /**
+ * content_mode 를 text → html 로 바꾸면 캐시가 다시 계산되어야 합니다.
+ *
+ * @effects mode_switch_recomputes_cache
+ */
+ #[Test]
+ public function switching_to_html_mode_recomputes_cache(): void
+ {
+ $post = $this->createPostViaModel([
+ 'content_mode' => 'text',
+ 'content' => '
',
+ ]);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url);
+
+ $post->fresh()->update(['content_mode' => 'html']);
+
+ $this->assertSame('/storage/uploads/promoted.jpg', $post->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * 필터가 비정상 값(비문자열/상한 초과)을 돌려줘도 저장이 실패하지 않아야 합니다.
+ */
+ #[Test]
+ public function filter_hook_invalid_return_degrades_to_null(): void
+ {
+ HookManager::addFilter(self::FILTER_HOOK, fn ($value) => str_repeat('a', 1500));
+
+ $post = $this->createPostViaModel([
+ 'content' => '
',
+ ]);
+
+ $this->assertNull($post->fresh()->content_thumbnail_url);
+ }
+}
diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
index d677fdd0..090ddb8b 100644
--- a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
+++ b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
@@ -112,4 +112,96 @@ class PostResourceThumbnailUrlTest extends BoardTestCase
$response['thumbnail'],
);
}
+
+ // ── 본문 첫 내부 이미지 캐시 폴백 (공개 이슈 #22) ──────────────
+
+ /**
+ * 이미지 첨부가 없으면 본문 캐시(content_thumbnail_url)로 폴백해야 합니다.
+ *
+ * @scenario image_source=content_internal_only, secrecy=normal
+ *
+ * @effects content_internal_image_fills_list_thumbnail
+ */
+ #[Test]
+ public function thumbnail_falls_back_to_content_cache_without_attachment(): void
+ {
+ $postId = $this->createTestPost([
+ 'content' => '
',
+ 'content_thumbnail_url' => '/storage/uploads/content.jpg',
+ ]);
+
+ $post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId);
+
+ $response = (new PostResource($post))->toArray(Request::create('/'));
+
+ $this->assertSame('/storage/uploads/content.jpg', $response['thumbnail']);
+ }
+
+ /**
+ * 이미지 첨부와 본문 캐시가 모두 있으면 첨부가 우선해야 합니다 (첨부 우선 정책).
+ *
+ * @scenario image_source=both, secrecy=normal
+ *
+ * @effects attachment_takes_precedence_over_content_image
+ */
+ #[Test]
+ public function attachment_takes_precedence_over_content_cache(): void
+ {
+ ['post' => $post, 'attachment' => $attachment] = $this->createPostWithAttachment('image/jpeg', [
+ 'content_thumbnail_url' => '/storage/uploads/content.jpg',
+ ]);
+
+ $response = (new PostResource($post))->toArray(Request::create('/'));
+
+ $this->assertSame(
+ '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview',
+ $response['thumbnail'],
+ );
+ }
+
+ /**
+ * 비밀글은 본문 캐시가 있어도 썸네일이 null 이어야 합니다.
+ *
+ * 에디터 이미지는 공개 hash 서빙이라 첨부와 달리 서빙측 차단이 없다 —
+ * 이 게이트가 유일한 차단선이므로 폴백은 반드시 게이트 뒤에 있어야 한다.
+ *
+ * @scenario image_source=content_internal_only, secrecy=secret_unauthorized
+ *
+ * @effects secret_post_thumbnail_stays_null
+ */
+ #[Test]
+ public function secret_post_thumbnail_null_even_with_content_cache(): void
+ {
+ $postId = $this->createTestPost([
+ 'is_secret' => true,
+ 'content' => '
',
+ 'content_thumbnail_url' => '/storage/uploads/secret.jpg',
+ ]);
+
+ $post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId);
+
+ $response = (new PostResource($post))->toArray(Request::create('/'));
+
+ $this->assertArrayHasKey('thumbnail', $response);
+ $this->assertNull($response['thumbnail'], '비밀글은 본문 캐시 URL 도 방출되지 않아야 합니다.');
+ }
+
+ /**
+ * 첨부도 본문 캐시도 없으면 null 이어야 합니다.
+ *
+ * @scenario image_source=none, secrecy=normal
+ *
+ * @effects external_only_content_yields_null
+ */
+ #[Test]
+ public function thumbnail_is_null_without_attachment_and_cache(): void
+ {
+ $postId = $this->createTestPost(['content' => '이미지 없음
']);
+
+ $post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId);
+
+ $response = (new PostResource($post))->toArray(Request::create('/'));
+
+ $this->assertNull($response['thumbnail']);
+ }
}
diff --git a/modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml b/modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml
new file mode 100644
index 00000000..46d259df
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/tests/scenarios/board-content-thumbnail-fallback.yaml
@@ -0,0 +1,48 @@
+feature: 본문 첫 이미지 목록 썸네일 폴백
+
+description: |
+ 공개 이슈 #22 — 에디터로 본문에 삽입한 이미지가 카드형·갤러리형 목록 썸네일에
+ 반영되지 않던 문제. 저장 시 본문 첫 내부 이미지 URL 을 board_posts.content_thumbnail_url
+ 에 캐시하고, 이미지 첨부가 없을 때 PostResource thumbnail 이 이를 폴백으로 사용한다.
+ 내부 URL 판정은 TrustedScriptHosts::normalizeForOriginCheck 공유 정규화를 따른다.
+
+axes:
+ image_source: [attachment_only, content_internal_only, both, content_external_only, none]
+ secrecy: [normal, secret_unauthorized]
+
+exclusions:
+ - { image_source: attachment_only, secrecy: secret_unauthorized, reason: "비밀글 게이트는 image_source 와 무관하게 최상단에서 null — 대표 1조합(content_internal_only)으로 게이트를 검증하고 첨부 축은 기존 PostResourceThumbnailUrlTest 비밀글 케이스가 커버" }
+ - { image_source: both, secrecy: secret_unauthorized, reason: "동일 — 게이트 선행으로 소스 축 무의미, 대표 조합으로 검증" }
+ - { image_source: content_external_only, secrecy: secret_unauthorized, reason: "동일" }
+ - { image_source: none, secrecy: secret_unauthorized, reason: "동일" }
+
+sub_flows:
+ - id: backfill
+ description: 1.1.0 업그레이드 스텝이 기존 게시물을 chunkById 백필
+ effects:
+ - backfill_fills_legacy_rows
+ - backfill_idempotent_second_run
+ - backfill_keeps_external_only_null
+ - id: recompute
+ description: content/content_mode 변경 시에만 saving 이벤트가 재계산
+ effects:
+ - recompute_on_content_change
+ - no_recompute_on_unrelated_update
+ - filter_hook_can_override_or_block
+ - text_mode_content_never_caches
+ - mode_switch_recomputes_cache
+
+effects:
+ - attachment_takes_precedence_over_content_image
+ - content_internal_image_fills_list_thumbnail
+ - external_only_content_yields_null
+ - secret_post_thumbnail_stays_null
+ - detail_and_og_share_same_fallback
+
+test_files:
+ - tests/Unit/Support/HtmlImageExtractorTest.php
+ - modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php
+ - modules/_bundled/sirsoft-board/tests/Unit/Models/PostContentThumbnailTest.php
+ - modules/_bundled/sirsoft-board/tests/Feature/User/PostListContentThumbnailTest.php
+ - modules/_bundled/sirsoft-board/tests/Feature/Upgrade/ContentThumbnailBackfillTest.php
+ - modules/_bundled/sirsoft-board/tests/Playwright/specs/user/board-content-thumbnail.spec.ts
diff --git a/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php
new file mode 100644
index 00000000..018ee4b3
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/upgrades/Upgrade_1_1_0.php
@@ -0,0 +1,17 @@
+logger->warning('[board:1.1.0] board_posts.content_thumbnail_url 미존재 — 스킵');
+
+ return;
+ }
+
+ $filled = 0;
+ $noCandidate = 0;
+
+ // text 모드 본문은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상
+ DB::table(self::POSTS_TABLE)
+ ->whereNull('content_thumbnail_url')
+ ->where('content_mode', 'html')
+ ->whereNotNull('content')
+ ->where('content', '!=', '')
+ ->orderBy('id')
+ ->select('id', 'content')
+ ->chunkById(200, function ($posts) use (&$filled, &$noCandidate) {
+ foreach ($posts as $post) {
+ $url = $this->firstInternalImageUrl((string) $post->content);
+
+ if ($url === null) {
+ $noCandidate++;
+
+ continue;
+ }
+
+ DB::table(self::POSTS_TABLE)
+ ->where('id', $post->id)
+ ->update(['content_thumbnail_url' => $url]);
+
+ $filled++;
+ }
+ });
+
+ $context->logger->info("[board:1.1.0] 본문 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}");
+ }
+
+ /**
+ * 본문 HTML 에서 첫 번째 내부 이미지 URL 을 추출합니다 (1.1.0 동결 사본).
+ *
+ * @param string $html 본문 HTML
+ * @return string|null 첫 내부 이미지 URL (없으면 null)
+ */
+ private function firstInternalImageUrl(string $html): ?string
+ {
+ foreach ($this->imageSources($html) as $src) {
+ $resolved = $this->resolveInternal($src);
+
+ if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) {
+ continue;
+ }
+
+ return $resolved;
+ }
+
+ return null;
+ }
+
+ /**
+ * HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본).
+ *
+ * @param string $html 본문 HTML
+ * @return array src 목록
+ */
+ private function imageSources(string $html): array
+ {
+ if (trim($html) === '' || ! class_exists(\DOMDocument::class)) {
+ return [];
+ }
+
+ $document = new \DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+
+ try {
+ $loaded = $document->loadHTML(
+ ''
+ .$html.'
',
+ LIBXML_NOERROR | LIBXML_NOWARNING
+ );
+
+ if (! $loaded) {
+ return [];
+ }
+
+ $sources = [];
+
+ foreach ($document->getElementsByTagName('img') as $img) {
+ if (! $img instanceof \DOMElement) {
+ continue;
+ }
+
+ $src = trim($img->getAttribute('src'));
+
+ if ($src !== '') {
+ $sources[] = $src;
+ }
+ }
+
+ return $sources;
+ } catch (\Throwable) {
+ return [];
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+ }
+
+ /**
+ * src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함).
+ *
+ * @param string $src img src 속성값
+ * @return string|null 내부 URL (외부/비허용 스킴이면 null)
+ */
+ private function resolveInternal(string $src): ?string
+ {
+ $trimmed = trim($src);
+
+ if ($trimmed === '') {
+ return null;
+ }
+
+ $normalized = $this->normalizeForOriginCheck($trimmed);
+
+ if ($normalized === '' || str_starts_with($normalized, '//')) {
+ return null;
+ }
+
+ if (str_starts_with($normalized, '/')) {
+ return $normalized;
+ }
+
+ $scheme = parse_url($normalized, PHP_URL_SCHEME);
+
+ if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
+ $host = parse_url($normalized, PHP_URL_HOST);
+ $appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
+
+ if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
+ $path = parse_url($normalized, PHP_URL_PATH);
+ $query = parse_url($normalized, PHP_URL_QUERY);
+
+ return (is_string($path) && $path !== '' ? $path : '/')
+ .(is_string($query) && $query !== '' ? '?'.$query : '');
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본).
+ *
+ * @param string $url 원본 URL
+ * @return string 정규화된 URL
+ */
+ private function normalizeForOriginCheck(string $url): string
+ {
+ $stripped = str_replace(["\t", "\n", "\r"], '', $url);
+ $slashed = str_replace('\\', '/', $stripped);
+
+ return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index a7345482..932a7524 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.2.0] - 2026-08-22
+
+### Added
+
+- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
+
+### Fixed
+
+- 카테고리 페이지의 공유 미리보기 이미지(og:image)가 어떤 경우에도 채워지지 않던 문제를 수정했습니다. 이제 카테고리에 등록된 첫 이미지가 사용됩니다.
+
## [1.1.2] - 2026-08-22
### Security
diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json
index ca4d7301..c1ca57bd 100644
--- a/modules/_bundled/sirsoft-ecommerce/composer.json
+++ b/modules/_bundled/sirsoft-ecommerce/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-ecommerce",
"description": "Ecommerce module for Gnuboard7",
"type": "library",
- "version": "1.1.2",
+ "version": "1.2.0",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php
new file mode 100644
index 00000000..e054b782
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_ecommerce_products_table.php
@@ -0,0 +1,44 @@
+string('content_thumbnail_url', 1000)
+ ->nullable()
+ ->after('description_mode')
+ ->comment('설명 첫 내부 이미지 URL 캐시 — 상품 이미지가 없을 때 목록/공유 썸네일 폴백');
+ }
+ });
+ }
+
+ /**
+ * 마이그레이션 롤백
+ */
+ public function down(): void
+ {
+ if (Schema::hasTable('ecommerce_products')) {
+ $columns = Schema::getColumnListing('ecommerce_products');
+
+ Schema::table('ecommerce_products', function (Blueprint $table) use ($columns) {
+ if (in_array('content_thumbnail_url', $columns)) {
+ $table->dropColumn('content_thumbnail_url');
+ }
+ });
+ }
+ }
+};
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md
index 11707de4..27763777 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/categories.md
@@ -1369,6 +1369,6 @@ HTTP/1.1 200
-**설명** slug로 단일 공개 카테고리와 직계 자식을 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@show`가 `CategoryService::getPublicCategoryBySlug()`를 호출해 활성 자식(`activeChildren`)과 이미지를 함께 로드합니다. 조회된 카테고리가 비활성(`is_active=false`)이면 없는 것으로 간주해 404를 반환하며, 응답에는 상위 경로를 나타내는 `breadcrumb` 배열과 `products_count` 집계가 포함됩니다. 스토어프론트 카테고리 상세/목록 페이지 진입 시 사용합니다.
+**설명** slug로 단일 공개 카테고리와 직계 자식을 조회합니다. 인증이 필요 없는 공개 엔드포인트이며, `Public\CategoryController@show`가 `CategoryService::getPublicCategoryBySlug()`를 호출해 활성 자식(`activeChildren`)과 이미지를 함께 로드합니다. 조회된 카테고리가 비활성(`is_active=false`)이면 없는 것으로 간주해 404를 반환하며, 응답에는 상위 경로를 나타내는 `breadcrumb` 배열과 `products_count` 집계가 포함됩니다. 스토어프론트 카테고리 상세/목록 페이지 진입 시 사용합니다. 1.2.0 부터 `thumbnail_url` 필드(로드된 카테고리 이미지 첫 건의 URL, 없으면 `null`)가 추가되어 카테고리 페이지의 공유 미리보기 이미지(og:image)에 사용됩니다.
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md
index 58f95aa8..c5e7888f 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/products.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/products.md
@@ -123,7 +123,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) |
| sku | string | `JK-0100` | 재고관리코드(SKU) |
-| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
+| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) |
| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) |
@@ -413,7 +413,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource`). 성공 시 HTTP 201
| hs_code | string | `null` | HS 코드 (수출입 관세 분류 코드) |
| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) |
| thumbnail_hash | string | `null` | 대표 이미지 해시 |
-| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL |
+| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) |
| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) |
| meta_keywords | array | `null` | SEO 메타 키워드 배열 |
@@ -1084,7 +1084,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품
| hs_code | string | `null` | HS 코드 |
| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) |
| thumbnail_hash | string | `null` | 대표 이미지 해시 |
-| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL |
+| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) |
| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) |
| meta_keywords | array | `null` | SEO 메타 키워드 배열 |
@@ -2011,7 +2011,7 @@ _단건 응답: `data` 객체의 필드 (`ProductResource` — 수정 후 상품
| hs_code | string | `null` | HS 코드 |
| images | array | `[]` | 상품 이미지 목록 (images 관계 로드 시) |
| thumbnail_hash | string | `null` | 대표 이미지 해시 |
-| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL |
+| thumbnail_url | string | `null` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| meta_title | object | `null` | SEO 메타 제목 (다국어 JSON) |
| meta_description | object | `null` | SEO 메타 설명 (다국어 JSON) |
| meta_keywords | array | `null` | SEO 메타 키워드 배열 |
@@ -2249,7 +2249,7 @@ _단건 응답: `data` 객체의 필드._
| seo_sync_description | boolean | `true` | SEO 설명 동기화 여부 (1: 상품 설명으로 자동 채움, 0: 직접 입력 보존) |
| barcode | null | `null` | 바코드 |
| hs_code | null | `null` | HS 코드 (관세 분류) |
-| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
+| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| categories | array | `[{"id":29,"name":{"ko":"의류","en":"Clothing"},"name_locali…` | 소속 카테고리 목록 (breadcrumb 포함 — 복사 폼의 카테고리 표시용) |
**응답 예시**
@@ -2636,7 +2636,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) |
| sku | string | `JK-0100` | 재고관리코드(SKU) |
-| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
+| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) |
| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) |
@@ -2766,7 +2766,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
| name_localized | string | `겨울 패딩 점퍼 #100` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
| product_code | string | `GGK6A9N8PXNR35OQ` | 상품코드 (상품 고유 관리 식별자) |
| sku | string | `JK-0100` | 재고관리코드(SKU) |
-| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
+| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| list_price | integer | `200000` | 정가 (기본통화 자릿수로 정규화된 값) |
| list_price_formatted | string | `200,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
| selling_price | integer | `169000` | 판매가 (기본통화 자릿수로 정규화된 값) |
@@ -2913,7 +2913,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생
| name_localized | string | `프리미엄 브이넥 티셔츠 #5` | `name` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
| product_code | string | `4R5OG8VYO0JKG1WQ` | 상품코드 (상품 고유 관리 식별자) |
| sku | string | `TS-0005` | 재고관리코드(SKU) |
-| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
+| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| list_price | integer | `34000` | 정가 (기본통화 자릿수로 정규화된 값) |
| list_price_formatted | string | `34,000원` | `list_price` 값의 표시용 포맷 문자열 (통화/용량/일시 등 로케일·단위 포맷) |
| selling_price | integer | `27000` | 판매가 (기본통화 자릿수로 정규화된 값) |
@@ -3062,7 +3062,7 @@ _목록 응답: `data[]` 배열 항목의 필드 (`ProductListResource` — `GET
| name_localized | string | `eum et quia` | `name` 의 현재 로케일 해석 값 |
| product_code | string | `PROD-GJUX-1484` | 상품코드 (상품 고유 관리 식별자) |
| sku | string | `SKU-MRAD-9306` | 재고관리코드(SKU) |
-| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL |
+| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/products/images/3f7a1c9e/download` | 대표 이미지 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| list_price | integer | `112594` | 정가 (기본통화 자릿수로 정규화) |
| list_price_formatted | string | `112,594원` | `list_price` 의 표시용 통화 포맷 문자열 |
| selling_price | integer | `88949` | 판매가 (기본통화 자릿수로 정규화) |
@@ -3186,7 +3186,7 @@ _단건 응답: `data` 객체의 필드._
| description_localized | string | `부드러운 면 100% 손수건 3매 세트입니다.
` | `description` 의 현재 로케일 해석 값 (다국어 필드를 표시용 문자열로 해석) |
| description_mode | string | `text` | 설명 모드: text(텍스트), html(HTML) |
| images | array | `[{"id":7,"hash":"7df7761cdf16","original_filename":"produ…` | 상품 이미지 목록 (각 항목: hash·url·alt_text·is_thumbnail·sort_order 등, images 관계 로드 시) |
-| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | thumbnail URL |
+| thumbnail_url | string | `/api/modules/sirsoft-ecommerce/produc…` | 대표 이미지 다운로드 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| meta_title | null | `null` | SEO 제목 (다국어 JSON) |
| meta_description | object | `{"ko":"면 손수건 3매입 #1 의 직접 입력 SEO 설명입니다.","en":"Custom SEO …` | SEO 설명 (다국어 JSON) |
| meta_keywords | null | `null` | SEO 키워드 (배열) |
diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md
index 49e72b9a..bb101382 100644
--- a/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md
+++ b/modules/_bundled/sirsoft-ecommerce/docs/api/wishlist.md
@@ -59,7 +59,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._
| name_localized | string | `상품명` | 현재 로케일로 해석된 상품명 |
| product_code | string | `P0000001` | 상품 코드 |
| sku | string \| null | `SKU-001` | 재고 관리 코드 |
-| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL |
+| thumbnail_url | string \| null | `/storage/products/thumb.jpg` | 대표 썸네일 이미지 URL — 상품 이미지가 없으면 상세설명의 첫 내부 이미지 URL 로 폴백한다(외부 주소 이미지는 제외 — 1.2.0+) |
| list_price | number | `30000` | 정가 (기본 통화 기준 반올림) |
| list_price_formatted | string | `30,000원` | 정가 표시용 포맷 문자열 |
| selling_price | number | `24000` | 판매가 (기본 통화 기준 반올림) |
diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json
index 02aab554..4d8c80fe 100644
--- a/modules/_bundled/sirsoft-ecommerce/module.json
+++ b/modules/_bundled/sirsoft-ecommerce/module.json
@@ -5,13 +5,13 @@
"ko": "이커머스",
"en": "Ecommerce"
},
- "version": "1.1.2",
+ "version": "1.2.0",
"license": "MIT",
"description": {
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
"en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management"
},
- "g7_version": ">=7.0.7",
+ "g7_version": ">=7.0.9",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php
index d40f81f2..e418ae4f 100644
--- a/modules/_bundled/sirsoft-ecommerce/module.php
+++ b/modules/_bundled/sirsoft-ecommerce/module.php
@@ -2368,13 +2368,11 @@ class Module extends AbstractModule
? (str_starts_with($imageRaw, 'http') ? $imageRaw : url($imageRaw))
: '';
+ // thumbnail_width/height 는 어떤 Resource 도 방출하지 않는 死키였다 — 값 공급이
+ // 불가능한 축이므로 제거하고 크기 메타는 코어 기본값 폴백에 맡긴다 (공개 #22 부수 정리)
return array_filter([
'type' => 'product',
'image' => $image,
- 'image_width' => isset($product['thumbnail_width']) && (int) $product['thumbnail_width'] > 0
- ? (int) $product['thumbnail_width'] : null,
- 'image_height' => isset($product['thumbnail_height']) && (int) $product['thumbnail_height'] > 0
- ? (int) $product['thumbnail_height'] : null,
'image_alt' => $name,
'extra' => $extra,
], fn ($v) => $v !== null && $v !== '' && $v !== []);
diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json
index 7083fbfc..7ca37fa7 100644
--- a/modules/_bundled/sirsoft-ecommerce/package-lock.json
+++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.1.2",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-ecommerce",
- "version": "1.1.2",
+ "version": "1.2.0",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json
index 105f9f6d..3ae99e0a 100644
--- a/modules/_bundled/sirsoft-ecommerce/package.json
+++ b/modules/_bundled/sirsoft-ecommerce/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-ecommerce",
- "version": "1.1.2",
+ "version": "1.2.0",
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php
index 71af32a6..ed94e496 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Resources/PublicCategoryDetailResource.php
@@ -15,7 +15,7 @@ class PublicCategoryDetailResource extends BaseApiResource
/**
* 리소스를 배열로 변환합니다.
*
- * @param Request $request 요청
+ * @param Request $request 요청
* @return array
*/
public function toArray(Request $request): array
@@ -31,6 +31,10 @@ class PublicCategoryDetailResource extends BaseApiResource
'parent_id' => $this->parent_id,
'products_count' => $this->products_count ?? 0,
'breadcrumb' => $this->getBreadcrumb(),
+ // 카테고리 og:image 생산자 — 로드된 images 첫 건의 URL (재쿼리 금지,
+ // 로드 여부는 Repository/Service 가 결정 — whenLoaded 단일 가드).
+ // module.php seoOpenGraph 의 category.data.thumbnail_url 소비처가 읽는다.
+ 'thumbnail_url' => $this->whenLoaded('images', fn () => $this->images->first()?->download_url, null),
'images' => $this->whenLoaded('images', function () {
return $this->images->map(function ($image) {
return [
diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php
index 642951ff..a6012d19 100644
--- a/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php
+++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Product.php
@@ -5,6 +5,7 @@ namespace Modules\Sirsoft\Ecommerce\Models;
use App\Casts\AsUnicodeJson;
use App\Extension\HookManager;
use App\Search\Contracts\FulltextSearchable;
+use App\Support\HtmlImageExtractor;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -93,6 +94,7 @@ class Product extends Model implements FulltextSearchable
'common_info_id',
'description',
'description_mode',
+ 'content_thumbnail_url',
'meta_title',
'meta_description',
'meta_keywords',
@@ -133,6 +135,67 @@ class Product extends Model implements FulltextSearchable
'tax_status' => ProductTaxStatus::class,
];
+ /**
+ * 모델 이벤트 등록
+ *
+ * 설명 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다 —
+ * 목록 쿼리는 description 을 SELECT 하지 않으므로 조회 시점 파싱이 불가하다.
+ * 시더·테스트의 Product::create() 직접 호출까지 커버되는 유일 지점이 모델
+ * saving 이벤트다 (공개 이슈 #22 동종 — board 와 동형).
+ */
+ protected static function booted(): void
+ {
+ static::saving(function (Product $product) {
+ if ($product->exists
+ && ! $product->isDirty('description')
+ && ! $product->isDirty('description_mode')) {
+ return;
+ }
+
+ // text 모드 설명은 이스케이프 렌더(이미지 미표시) — 캐시하지 않는다
+ if ($product->description_mode !== 'html') {
+ $product->content_thumbnail_url = null;
+
+ return;
+ }
+
+ // description 은 다국어 JSON — 기본 로케일 우선, 없으면 배열 순서대로
+ // 첫 내부 이미지가 나올 때까지 시도 (레거시 평문 문자열도 수용)
+ $extracted = null;
+ $candidates = [];
+ $description = $product->description;
+ $htmls = is_array($description)
+ ? array_values(array_filter(
+ [$description[config('app.locale')] ?? null, ...array_values($description)],
+ fn ($html) => is_string($html) && $html !== ''
+ ))
+ : (is_string($description) && $description !== '' ? [$description] : []);
+
+ foreach ($htmls as $html) {
+ $candidates = array_merge($candidates, HtmlImageExtractor::candidates($html));
+ $extracted ??= HtmlImageExtractor::firstInternal($html);
+
+ if ($extracted !== null) {
+ break;
+ }
+ }
+
+ // 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅.
+ // 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
+ $value = HookManager::applyFilters(
+ 'sirsoft-ecommerce.product.filter_content_thumbnail',
+ $extracted,
+ $product,
+ $candidates
+ );
+
+ // 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등
+ $product->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000
+ ? $value
+ : null;
+ });
+ }
+
/**
* 이 상품의 옵션 관계 (color/size 등 가변 옵션 정의).
*
@@ -190,13 +253,15 @@ class Product extends Model implements FulltextSearchable
$thumbnailImage = $this->images->firstWhere('is_thumbnail', true)
?? $this->images->first();
- return $thumbnailImage?->download_url;
+ // 상품 이미지가 없으면 설명 첫 내부 이미지 캐시로 폴백 (공개 이슈 #22 동종).
+ // 추가 쿼리 없음 — 이미 로드된 속성 읽기만 (쿼리 수 잠금 테스트 준수)
+ return $thumbnailImage?->download_url ?? ($this->content_thumbnail_url ?: null);
}
$thumbnailImage = $this->images()->where('is_thumbnail', true)->first()
?? $this->images()->first();
- return $thumbnailImage?->download_url;
+ return $thumbnailImage?->download_url ?? ($this->content_thumbnail_url ?: null);
}
/**
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php
new file mode 100644
index 00000000..833fa679
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php
@@ -0,0 +1,226 @@
+ 'ko']);
+
+ // 비인증 공개 접근 (optional.sanctum + guest 권한)
+ $productReadPerm = Permission::firstOrCreate(
+ ['identifier' => 'sirsoft-ecommerce.user-products.read'],
+ ['name' => ['ko' => '상품 조회', 'en' => 'View Products'], 'type' => PermissionType::User]
+ );
+ $guestRole = Role::firstOrCreate(
+ ['identifier' => 'guest'],
+ ['name' => ['ko' => '비회원', 'en' => 'Guest'], 'is_active' => true]
+ );
+ $guestRole->permissions()->syncWithoutDetaching([$productReadPerm->id]);
+ PermissionMiddleware::clearGuestRoleCache();
+ }
+
+ /**
+ * @param array $attributes 덮어쓸 속성
+ * @return Product 설명 이미지만 가진 상품
+ */
+ private function createContentImageProduct(array $attributes = []): Product
+ {
+ return Product::factory()->onSale()->create(array_merge([
+ 'description_mode' => 'html',
+ 'description' => ['ko' => '설명
'],
+ ], $attributes));
+ }
+
+ /**
+ * 목록 응답에서 특정 상품 행을 찾습니다.
+ *
+ * @param int $productId 상품 ID
+ * @return array|null 목록 행
+ */
+ private function findListItem(int $productId): ?array
+ {
+ $response = $this->getJson('/api/modules/sirsoft-ecommerce/products?per_page=50');
+ $response->assertStatus(200);
+
+ foreach ($response->json('data.data') ?? [] as $item) {
+ if (($item['id'] ?? null) === $productId) {
+ return $item;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, locale_content=default_locale
+ *
+ * @effects content_image_fills_product_thumbnail
+ */
+ #[Test]
+ public function list_thumbnail_filled_from_description_without_product_image(): void
+ {
+ $product = $this->createContentImageProduct();
+
+ $item = $this->findListItem($product->id);
+
+ $this->assertNotNull($item, '목록에 상품이 있어야 합니다.');
+ $this->assertSame('/storage/products/desc-image.jpg', $item['thumbnail_url']);
+ }
+
+ /**
+ * @scenario image_source=product_image_only, locale_content=default_locale
+ *
+ * @effects product_image_takes_precedence
+ */
+ #[Test]
+ public function product_image_takes_precedence_over_description_cache(): void
+ {
+ $product = $this->createContentImageProduct();
+
+ $image = ProductImage::create([
+ 'product_id' => $product->id,
+ 'original_filename' => 'main.jpg',
+ 'stored_filename' => 'main-'.uniqid().'.jpg',
+ 'disk' => 'modules',
+ 'path' => 'ecommerce/products/main.jpg',
+ 'mime_type' => 'image/jpeg',
+ 'file_size' => 1024,
+ 'is_thumbnail' => true,
+ 'sort_order' => 0,
+ 'collection' => 'main',
+ ]);
+
+ $item = $this->findListItem($product->id);
+
+ $this->assertNotNull($item);
+ $this->assertSame(
+ $image->download_url,
+ $item['thumbnail_url'],
+ '상품 이미지가 있으면 설명 캐시보다 우선해야 합니다.'
+ );
+ }
+
+ /**
+ * 상세(공개) 응답에도 같은 폴백이 반영되어야 합니다.
+ *
+ * @scenario image_source=both, locale_content=default_locale
+ *
+ * @effects detail_og_and_search_share_same_fallback
+ */
+ #[Test]
+ public function public_detail_thumbnail_uses_same_fallback(): void
+ {
+ $product = $this->createContentImageProduct();
+
+ $response = $this->getJson('/api/modules/sirsoft-ecommerce/products/'.$product->product_code);
+
+ $response->assertStatus(200);
+ $this->assertSame('/storage/products/desc-image.jpg', $response->json('data.thumbnail_url'));
+ }
+
+ /**
+ * 검색 표면(searchByKeyword)의 상품도 폴백된 썸네일에 도달해야 합니다.
+ *
+ * @effects detail_og_and_search_share_same_fallback
+ */
+ #[Test]
+ public function search_surface_reaches_fallback_thumbnail(): void
+ {
+ $product = $this->createContentImageProduct();
+
+ // FULLTEXT 는 트랜잭션 내 신규 행을 보지 못한다 — 보조필드(product_code) 합집합
+ // 매칭으로 같은 검색 쿼리 경로(관계 로드·게이트·컬럼 도달)를 검증한다
+ $page = app(ProductService::class)->searchByKeyword($product->product_code);
+ $found = collect($page->items())->firstWhere('id', $product->id);
+
+ $this->assertNotNull($found, '검색 결과에 상품이 있어야 합니다.');
+ $this->assertSame('/storage/products/desc-image.jpg', $found->getThumbnailUrl());
+ }
+
+ /**
+ * 비공개 상품은 설명 캐시가 있어도 목록/검색에 노출되지 않아야 합니다 (게이트 불변).
+ *
+ * @scenario image_source=content_internal_only, locale_content=default_locale
+ *
+ * @effects hidden_product_stays_hidden
+ */
+ #[Test]
+ public function hidden_product_is_not_exposed_by_fallback(): void
+ {
+ $hidden = $this->createContentImageProduct([
+ 'display_status' => ProductDisplayStatus::HIDDEN,
+ 'name' => ['ko' => '비공개폴백상품', 'en' => 'HiddenFallbackProduct'],
+ ]);
+
+ $this->assertNull($this->findListItem($hidden->id), '비공개 상품은 목록에 없어야 합니다.');
+
+ $page = app(ProductService::class)->searchByKeyword($hidden->product_code);
+ $this->assertNull(
+ collect($page->items())->firstWhere('id', $hidden->id),
+ '비공개 상품은 검색에도 없어야 합니다.'
+ );
+ }
+
+ /**
+ * @scenario image_source=content_external_only, locale_content=default_locale
+ *
+ * @effects external_only_description_yields_null
+ */
+ #[Test]
+ public function external_only_description_yields_null_thumbnail(): void
+ {
+ $product = Product::factory()->onSale()->create([
+ 'description_mode' => 'html',
+ 'description' => ['ko' => '
'],
+ ]);
+
+ $item = $this->findListItem($product->id);
+
+ $this->assertNotNull($item);
+ $this->assertNull($item['thumbnail_url']);
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, locale_content=other_locale_only
+ *
+ * @effects other_locale_image_used_when_default_has_none
+ */
+ #[Test]
+ public function other_locale_only_image_fills_thumbnail(): void
+ {
+ $product = Product::factory()->onSale()->create([
+ 'description_mode' => 'html',
+ 'description' => [
+ 'ko' => '이미지 없는 한국어
',
+ 'en' => '
',
+ ],
+ ]);
+
+ $item = $this->findListItem($product->id);
+
+ $this->assertNotNull($item);
+ $this->assertSame('/storage/products/en-image.jpg', $item['thumbnail_url']);
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php
new file mode 100644
index 00000000..5959b4b0
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php
@@ -0,0 +1,82 @@
+ ['ko' => '썸네일 카테고리', 'en' => 'Thumbnail Category'],
+ 'slug' => 'thumb-cat-'.uniqid(),
+ 'is_active' => true,
+ 'depth' => 0,
+ 'path' => '',
+ 'sort_order' => 0,
+ ], $attributes));
+ }
+
+ /**
+ * @effects category_detail_emits_thumbnail_url
+ */
+ #[Test]
+ public function detail_emits_first_image_url_as_thumbnail(): void
+ {
+ $category = $this->createCategory();
+
+ $image = CategoryImage::create([
+ 'category_id' => $category->id,
+ 'original_filename' => 'cat.jpg',
+ 'stored_filename' => 'cat-'.uniqid().'.jpg',
+ 'disk' => 'modules',
+ 'path' => 'category/thumb-test/cat.jpg',
+ 'mime_type' => 'image/jpeg',
+ 'file_size' => 11,
+ 'collection' => 'main',
+ 'sort_order' => 0,
+ ]);
+
+ $response = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$category->slug);
+
+ $response->assertStatus(200);
+ $this->assertSame(
+ $image->download_url,
+ $response->json('data.thumbnail_url'),
+ '로드된 images 첫 건의 URL 이 thumbnail_url 로 방출되어야 합니다.'
+ );
+ }
+
+ /**
+ * @effects category_detail_emits_thumbnail_url
+ */
+ #[Test]
+ public function detail_emits_null_thumbnail_without_images(): void
+ {
+ $category = $this->createCategory();
+
+ $response = $this->getJson('/api/modules/sirsoft-ecommerce/categories/'.$category->slug);
+
+ $response->assertStatus(200);
+ $this->assertArrayHasKey('thumbnail_url', $response->json('data'));
+ $this->assertNull($response->json('data.thumbnail_url'));
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php
new file mode 100644
index 00000000..8e86531b
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php
@@ -0,0 +1,151 @@
+ 'ko']);
+ }
+
+ /**
+ * 모델 이벤트를 우회해 레거시 상태 상품 행을 만듭니다 (업그레이드 전 등록분과 동일).
+ *
+ * @param array $attributes 덮어쓸 속성
+ * @return int 상품 ID
+ */
+ private function createLegacyRow(array $attributes = []): int
+ {
+ return DB::table('ecommerce_products')->insertGetId(array_merge([
+ 'name' => json_encode(['ko' => '레거시 상품'], JSON_UNESCAPED_UNICODE),
+ 'product_code' => strtoupper(bin2hex(random_bytes(8))),
+ 'list_price' => 10000,
+ 'selling_price' => 10000,
+ 'stock_quantity' => 10,
+ 'safe_stock_quantity' => 1,
+ 'sales_status' => 'on_sale',
+ 'display_status' => 'visible',
+ 'tax_status' => 'taxable',
+ 'tax_rate' => 10,
+ 'description' => json_encode(['ko' => '설명
'], JSON_UNESCAPED_UNICODE),
+ 'description_mode' => 'html',
+ 'content_thumbnail_url' => null,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $attributes));
+ }
+
+ private function runBackfill(): void
+ {
+ $context = new UpgradeContext('1.1.2', '1.2.0', '1.2.0', 'extension-upgrade');
+
+ (new Upgrade_1_2_0)->run($context);
+ }
+
+ /**
+ * @scenario backfill=legacy_row
+ *
+ * @effects backfill_fills_legacy_products
+ */
+ #[Test]
+ public function legacy_rows_are_backfilled_from_multilingual_description(): void
+ {
+ $legacy = $this->createLegacyRow([
+ 'description' => json_encode([
+ 'ko' => '글

',
+ 'en' => '
',
+ ], JSON_UNESCAPED_UNICODE),
+ ]);
+
+ $this->runBackfill();
+
+ $this->assertSame(
+ '/storage/products/legacy.jpg',
+ DB::table('ecommerce_products')->where('id', $legacy)->value('content_thumbnail_url'),
+ '기본 로케일의 첫 내부 이미지가 백필되어야 합니다 (외부 후보 건너뜀).'
+ );
+ }
+
+ /**
+ * @scenario backfill=text_mode
+ *
+ * @effects text_mode_description_never_caches
+ */
+ #[Test]
+ public function text_mode_and_filled_and_external_rows_are_untouched(): void
+ {
+ $textMode = $this->createLegacyRow([
+ 'description_mode' => 'text',
+ 'description' => json_encode(['ko' => '텍스트
'], JSON_UNESCAPED_UNICODE),
+ ]);
+ $filled = $this->createLegacyRow([
+ 'description' => json_encode(['ko' => '
'], JSON_UNESCAPED_UNICODE),
+ 'content_thumbnail_url' => '/storage/products/kept.jpg',
+ ]);
+ $external = $this->createLegacyRow([
+ 'description' => json_encode(['ko' => '
'], JSON_UNESCAPED_UNICODE),
+ ]);
+
+ $this->runBackfill();
+
+ $this->assertNull(DB::table('ecommerce_products')->where('id', $textMode)->value('content_thumbnail_url'));
+ $this->assertSame('/storage/products/kept.jpg', DB::table('ecommerce_products')->where('id', $filled)->value('content_thumbnail_url'));
+ $this->assertNull(DB::table('ecommerce_products')->where('id', $external)->value('content_thumbnail_url'));
+ }
+
+ /**
+ * @scenario backfill=idempotent
+ *
+ * @effects backfill_idempotent_second_run
+ */
+ #[Test]
+ public function second_run_is_idempotent(): void
+ {
+ $this->createLegacyRow([
+ 'description' => json_encode(['ko' => '
'], JSON_UNESCAPED_UNICODE),
+ ]);
+ $this->createLegacyRow([
+ 'description' => json_encode(['ko' => '이미지 없음
'], JSON_UNESCAPED_UNICODE),
+ ]);
+
+ $this->runBackfill();
+ $first = $this->snapshot();
+
+ $this->runBackfill();
+ $second = $this->snapshot();
+
+ $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
+ }
+
+ /**
+ * @return array 스냅샷
+ */
+ private function snapshot(): array
+ {
+ return DB::table('ecommerce_products')
+ ->orderBy('id')
+ ->get(['id', 'content_thumbnail_url'])
+ ->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url])
+ ->all();
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php
index f847d49b..2e4c72f4 100644
--- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php
@@ -69,6 +69,57 @@ class EcommerceModuleSeoTest extends ModuleTestCase
$this->assertStringContainsString('/api/modules/sirsoft-ecommerce/product-image/abc123', $og['image']);
}
+ /**
+ * 카테고리 og:image 출력 레벨 — thumbnail_url 이 있으면 image 가 절대 URL 로 방출된다.
+ *
+ * @scenario product-content-thumbnail-fallback
+ *
+ * @effects category_og_image_emitted_from_thumbnail_url
+ */
+ public function test_category_seo_og_defaults_emits_image_from_thumbnail_url(): void
+ {
+ $context = [
+ 'category' => [
+ 'data' => [
+ 'name' => ['ko' => '아우터', 'en' => 'Outer'],
+ 'thumbnail_url' => '/api/modules/sirsoft-ecommerce/category-image/cat123',
+ ],
+ ],
+ ];
+
+ $og = $this->module->seoOgDefaults('category', $context);
+
+ $this->assertArrayHasKey('image', $og);
+ $this->assertStringStartsWith('http', $og['image'], 'og:image 는 절대 URL 이어야 공유 카드가 인식합니다');
+ $this->assertStringContainsString('/api/modules/sirsoft-ecommerce/category-image/cat123', $og['image']);
+ $this->assertSame('아우터', $og['image_alt']);
+ }
+
+ /**
+ * 카테고리 og:image 출력 레벨 — 이미지 없는 카테고리는 image 키 자체가 미출력된다
+ * (빈 값은 array_filter 로 제거 → 코어 사이트 기본 OG 이미지 폴백에 위임).
+ *
+ * @scenario product-content-thumbnail-fallback
+ *
+ * @effects category_og_image_omitted_without_thumbnail
+ */
+ public function test_category_seo_og_defaults_omits_image_without_thumbnail(): void
+ {
+ $context = [
+ 'category' => [
+ 'data' => [
+ 'name' => '아우터',
+ 'thumbnail_url' => null,
+ ],
+ ],
+ ];
+
+ $og = $this->module->seoOgDefaults('category', $context);
+
+ $this->assertArrayNotHasKey('image', $og);
+ $this->assertSame('website', $og['type']);
+ }
+
/**
* 회귀: 상품 structured_data 도 다국어 array 안전 처리.
*/
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php
new file mode 100644
index 00000000..ce70a21a
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php
@@ -0,0 +1,206 @@
+ 'ko']);
+ }
+
+ protected function tearDown(): void
+ {
+ HookManager::clearFilter(self::FILTER_HOOK);
+ parent::tearDown();
+ }
+
+ /**
+ * @param array $attributes 덮어쓸 속성
+ * @return Product 생성된 상품
+ */
+ private function createProduct(array $attributes = []): Product
+ {
+ return Product::factory()->create(array_merge([
+ 'description_mode' => 'html',
+ ], $attributes));
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, locale_content=default_locale
+ *
+ * @effects content_image_fills_product_thumbnail
+ */
+ #[Test]
+ public function saving_extracts_first_internal_image_from_default_locale(): void
+ {
+ $product = $this->createProduct([
+ 'description' => [
+ 'ko' => '설명

',
+ 'en' => '
',
+ ],
+ ]);
+
+ $this->assertSame('/storage/products/ko-first.jpg', $product->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * 기본 로케일에 내부 이미지가 없으면 다른 로케일에서 찾아야 합니다.
+ *
+ * @scenario image_source=content_internal_only, locale_content=other_locale_only
+ *
+ * @effects other_locale_image_used_when_default_has_none
+ */
+ #[Test]
+ public function falls_back_to_other_locale_when_default_has_no_internal_image(): void
+ {
+ $product = $this->createProduct([
+ 'description' => [
+ 'ko' => '이미지 없는 한국어 설명
',
+ 'en' => '
',
+ ],
+ ]);
+
+ $this->assertSame('/storage/products/en-only.jpg', $product->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @scenario image_source=content_external_only, locale_content=default_locale
+ *
+ * @effects external_only_description_yields_null
+ */
+ #[Test]
+ public function external_only_description_yields_null(): void
+ {
+ $product = $this->createProduct([
+ 'description' => [
+ 'ko' => '
',
+ ],
+ ]);
+
+ $this->assertNull($product->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @scenario image_source=none, locale_content=default_locale
+ *
+ * @effects external_only_description_yields_null
+ */
+ #[Test]
+ public function description_without_image_yields_null(): void
+ {
+ $product = $this->createProduct([
+ 'description' => ['ko' => '이미지 없는 설명
'],
+ ]);
+
+ $this->assertNull($product->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * text 모드 설명은 리터럴 img 마크업이 있어도 캐시하지 않아야 합니다.
+ *
+ * @effects text_mode_description_never_caches
+ */
+ #[Test]
+ public function text_mode_description_is_never_cached(): void
+ {
+ $product = $this->createProduct([
+ 'description_mode' => 'text',
+ 'description' => ['ko' => '텍스트 설명의
마크업'],
+ ]);
+
+ $this->assertNull($product->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @effects recompute_on_description_change
+ */
+ #[Test]
+ public function description_change_recomputes_cache(): void
+ {
+ $product = $this->createProduct([
+ 'description' => ['ko' => '
'],
+ ]);
+
+ $product->update(['description' => ['ko' => '
']]);
+
+ $this->assertSame('/storage/products/new.jpg', $product->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @effects no_recompute_on_unrelated_update
+ */
+ #[Test]
+ public function unrelated_update_does_not_recompute(): void
+ {
+ $product = $this->createProduct([
+ 'description' => ['ko' => '
'],
+ ]);
+
+ DB::table('ecommerce_products')->where('id', $product->id)
+ ->update(['content_thumbnail_url' => '/storage/products/manual.jpg']);
+
+ $product->fresh()->update(['stock_quantity' => 7]);
+
+ $this->assertSame('/storage/products/manual.jpg', $product->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @effects filter_hook_can_override_or_block
+ */
+ #[Test]
+ public function filter_hook_can_override_or_block(): void
+ {
+ HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null);
+
+ $blocked = $this->createProduct([
+ 'description' => ['ko' => '
'],
+ ]);
+
+ $this->assertNull($blocked->fresh()->content_thumbnail_url);
+
+ HookManager::clearFilter(self::FILTER_HOOK);
+ HookManager::addFilter(self::FILTER_HOOK, fn ($value) => 'https://cdn.example.net/promoted.jpg');
+
+ $promoted = $this->createProduct([
+ 'description' => ['ko' => '설명
'],
+ ]);
+
+ $this->assertSame('https://cdn.example.net/promoted.jpg', $promoted->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * 상품 이미지가 없으면 getThumbnailUrl 이 캐시로 폴백해야 합니다 (추가 쿼리 없이).
+ *
+ * @effects product_image_takes_precedence
+ */
+ #[Test]
+ public function get_thumbnail_url_falls_back_to_cache_without_images(): void
+ {
+ $product = $this->createProduct([
+ 'description' => ['ko' => '
'],
+ ]);
+
+ $loaded = Product::with('images')->findOrFail($product->id);
+
+ $this->assertSame('/storage/products/cache.jpg', $loaded->getThumbnailUrl());
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml
new file mode 100644
index 00000000..81fa4938
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/tests/scenarios/product-content-thumbnail-fallback.yaml
@@ -0,0 +1,53 @@
+feature: 상품 설명 첫 이미지 목록/공유 썸네일 폴백
+
+description: |
+ 공개 이슈 #22 동종 — 상품 이미지 없이 상세설명(에디터)에만 이미지를 넣은 상품이
+ 목록·검색·og:image 6개 표면에서 이미지 없이 나가던 문제. 저장 시 설명(다국어 JSON)
+ 의 첫 내부 이미지 URL 을 ecommerce_products.content_thumbnail_url 에 캐시하고,
+ Product::getThumbnailUrl() 이 상품 이미지 부재 시 이를 폴백으로 사용한다.
+ 내부 URL 판정은 TrustedScriptHosts::normalizeForOriginCheck 공유 정규화를 따른다.
+
+axes:
+ image_source: [product_image_only, content_internal_only, both, content_external_only, none]
+ locale_content: [default_locale, other_locale_only]
+
+exclusions:
+ - { image_source: product_image_only, locale_content: other_locale_only, reason: "로케일 축은 설명 내용에만 작용 — 상품 이미지 우선순위와 직교라 default_locale 대표 조합으로 검증" }
+ - { image_source: both, locale_content: other_locale_only, reason: "동일 — 로케일 축은 추출 입력에만 작용, 우선순위 판정과 직교" }
+ - { image_source: content_external_only, locale_content: other_locale_only, reason: "동일 — 외부 제외 판정은 로케일 무관 (전 로케일 순회 후 null)" }
+ - { image_source: none, locale_content: other_locale_only, reason: "이미지 없음은 로케일 구분이 성립하지 않는다 (전 로케일 빈 결과)" }
+
+sub_flows:
+ - id: backfill
+ description: 1.2.0 업그레이드 스텝이 기존 상품을 chunkById 백필 (다국어 JSON 파싱 동결 사본)
+ effects:
+ - backfill_fills_legacy_products
+ - backfill_idempotent_second_run
+ - text_mode_description_never_caches
+ - id: recompute
+ description: description/description_mode 변경 시에만 saving 이벤트가 재계산
+ effects:
+ - recompute_on_description_change
+ - no_recompute_on_unrelated_update
+ - filter_hook_can_override_or_block
+ - id: category_og_producer
+ description: 死표현식이던 category.data.thumbnail_url 의 생산자 신설 (P3-B — 로드된 images 첫 건)
+ effects:
+ - category_detail_emits_thumbnail_url
+ - category_og_image_emitted_from_thumbnail_url
+ - category_og_image_omitted_without_thumbnail
+
+effects:
+ - product_image_takes_precedence
+ - content_image_fills_product_thumbnail
+ - other_locale_image_used_when_default_has_none
+ - external_only_description_yields_null
+ - hidden_product_stays_hidden
+ - detail_og_and_search_share_same_fallback
+
+test_files:
+ - modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/ProductContentThumbnailTest.php
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/ProductListContentThumbnailTest.php
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/Upgrade/ProductContentThumbnailBackfillTest.php
+ - modules/_bundled/sirsoft-ecommerce/tests/Feature/PublicCategoryThumbnailTest.php
+ - modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php
diff --git a/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php
new file mode 100644
index 00000000..132c4338
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_2_0.php
@@ -0,0 +1,17 @@
+logger->warning('[ecommerce:1.2.0] ecommerce_products.content_thumbnail_url 미존재 — 스킵');
+
+ return;
+ }
+
+ $filled = 0;
+ $noCandidate = 0;
+
+ // text 모드 설명은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상
+ DB::table(self::PRODUCTS_TABLE)
+ ->whereNull('content_thumbnail_url')
+ ->where('description_mode', 'html')
+ ->whereNotNull('description')
+ ->where('description', '!=', '')
+ ->orderBy('id')
+ ->select('id', 'description')
+ ->chunkById(200, function ($products) use (&$filled, &$noCandidate) {
+ foreach ($products as $product) {
+ $url = $this->firstInternalFromDescription((string) $product->description);
+
+ if ($url === null) {
+ $noCandidate++;
+
+ continue;
+ }
+
+ DB::table(self::PRODUCTS_TABLE)
+ ->where('id', $product->id)
+ ->update(['content_thumbnail_url' => $url]);
+
+ $filled++;
+ }
+ });
+
+ $context->logger->info("[ecommerce:1.2.0] 상품 설명 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}");
+ }
+
+ /**
+ * 다국어 JSON(또는 레거시 평문) 설명에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
+ *
+ * 기본 로케일 값 우선, 없으면 배열 순서대로 첫 내부 이미지가 나올 때까지 시도한다.
+ *
+ * @param string $rawDescription DB 원시 description 값
+ * @return string|null 첫 내부 이미지 URL (없으면 null)
+ */
+ private function firstInternalFromDescription(string $rawDescription): ?string
+ {
+ if (trim($rawDescription) === '') {
+ return null;
+ }
+
+ $decoded = json_decode($rawDescription, true);
+
+ $htmls = is_array($decoded)
+ ? array_values(array_filter(
+ [$decoded[config('app.locale')] ?? null, ...array_values($decoded)],
+ static fn ($html) => is_string($html) && $html !== ''
+ ))
+ : [$rawDescription];
+
+ foreach ($htmls as $html) {
+ $url = $this->firstInternalImageUrl($html);
+
+ if ($url !== null) {
+ return $url;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 단일 HTML 에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
+ *
+ * @param string $html 본문 HTML
+ * @return string|null 첫 내부 이미지 URL
+ */
+ private function firstInternalImageUrl(string $html): ?string
+ {
+ foreach ($this->imageSources($html) as $src) {
+ $resolved = $this->resolveInternal($src);
+
+ if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) {
+ continue;
+ }
+
+ return $resolved;
+ }
+
+ return null;
+ }
+
+ /**
+ * HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본).
+ *
+ * @param string $html 본문 HTML
+ * @return array src 목록
+ */
+ private function imageSources(string $html): array
+ {
+ if (trim($html) === '' || ! class_exists(\DOMDocument::class)) {
+ return [];
+ }
+
+ $document = new \DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+
+ try {
+ $loaded = $document->loadHTML(
+ ''
+ .$html.'
',
+ LIBXML_NOERROR | LIBXML_NOWARNING
+ );
+
+ if (! $loaded) {
+ return [];
+ }
+
+ $sources = [];
+
+ foreach ($document->getElementsByTagName('img') as $img) {
+ if (! $img instanceof \DOMElement) {
+ continue;
+ }
+
+ $src = trim($img->getAttribute('src'));
+
+ if ($src !== '') {
+ $sources[] = $src;
+ }
+ }
+
+ return $sources;
+ } catch (\Throwable) {
+ return [];
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+ }
+
+ /**
+ * src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함).
+ *
+ * @param string $src img src 속성값
+ * @return string|null 내부 URL (외부/비허용 스킴이면 null)
+ */
+ private function resolveInternal(string $src): ?string
+ {
+ $trimmed = trim($src);
+
+ if ($trimmed === '') {
+ return null;
+ }
+
+ $normalized = $this->normalizeForOriginCheck($trimmed);
+
+ if ($normalized === '' || str_starts_with($normalized, '//')) {
+ return null;
+ }
+
+ if (str_starts_with($normalized, '/')) {
+ return $normalized;
+ }
+
+ $scheme = parse_url($normalized, PHP_URL_SCHEME);
+
+ if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
+ $host = parse_url($normalized, PHP_URL_HOST);
+ $appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
+
+ if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
+ $path = parse_url($normalized, PHP_URL_PATH);
+ $query = parse_url($normalized, PHP_URL_QUERY);
+
+ return (is_string($path) && $path !== '' ? $path : '/')
+ .(is_string($query) && $query !== '' ? '?'.$query : '');
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본).
+ *
+ * @param string $url 원본 URL
+ * @return string 정규화된 URL
+ */
+ private function normalizeForOriginCheck(string $url): string
+ {
+ $stripped = str_replace(["\t", "\n", "\r"], '', $url);
+ $slashed = str_replace('\\', '/', $stripped);
+
+ return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
+ }
+}
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
index 419a3927..3fce4c60 100644
--- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
+++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json
@@ -1,15 +1,15 @@
{
"schema_version": "1.0",
- "generated_at": "2026-08-21T13:47:13+00:00",
+ "generated_at": "2026-08-22T12:22:17+00:00",
"generator": "g7 vendor-bundle:build",
"target": "module:sirsoft-ecommerce",
- "composer_json_sha256": "6dc16ed7acb614593e17d97cf3230af0935e3e4910000026b4a7062d4f9de031",
+ "composer_json_sha256": "0556835aa543c462efe9132123b57234fddb66e482898144e7930f6cbedb1a9d",
"composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d",
- "zip_sha256": "b1f6cfcb3fea1c68be7ffd55074acab8c2d51c2750e9c036a678abc40ebee405",
- "zip_size": 435549,
+ "zip_sha256": "3127bf97f4342c91cffc0d729eaa70f2a1f74a4a4015288081a082e665ccb9c0",
+ "zip_size": 435548,
"package_count": 1,
"php_requirement": "^8.2",
- "g7_version": "7.0.8",
+ "g7_version": "7.0.9",
"packages": [
{
"name": "ezyang/htmlpurifier",
diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip
index 3d732ca64e2651613a1fc772d1780fb424c06c1c..0a4034344bb108a27c296630c44eb327fede01c8 100644
GIT binary patch
delta 8573
zcmaKxcU)A*_Q1Kbdxu?EU_lgViW;Mcf)o*{3syj|R|G3|DQYZOXeMY9Y%3gW2%^TA
zL_v+1pij+vn%MD~7*T1lU{_*{MihQ$?%;Aad7qzuxO2Ye%*;7w&di;AsXXU!>zsqd
z&`5>S)`Xa-On%zero`fzpuk^!li()(_8`Bq-$SN~E?nWMtdf9@sFuK?c62(CNR}#A
zD>drc7Ab77Q*0ZPdB;5k6O&F#6BB0>m{%n_>5n>Dr+%~c$n2;l-^ztI?OgUk1exhE
zbIOi?jh-?5?dAtQ_F-W^^qEjoR#(54l4U9`>K`QSLcpix_an%AJwN3
zZIxq2_wS@@*iy7V@4NJ48*O@;zIyX(+^cV+29DQVbnDelbFBPk#h%^%zgV|**}MGM
z*j`~{cRqOdE>n4V{gl)hJMKg{c2j=&rK@SH4hIsSt@6vi{U-mF_q>4C+h&(v`>`~4
zye^>Y=VlhS8{>lC>JD_wJg|13)d`>BKU>T{BmBC2;L>jw$E}ZE-Tlk#+}*Q2y>#)1
zhFz*z+N(}&&yUrV^~~Pc^URfMTwks*T9-p_ob#-t1hO6%{D@D7qTNTnF*CABvBE3OQZ>~N4!NJ^#}C|^Kn
zX<4RXUvJ4|W|?AZ56J}MLX#EL+$b7MiG#glnL?DxjZ!OplX8%>4y6~0lzCkYy>s6x
z)ukopm6-vO8K!5hv<84(ap*Q8Eq$UDOZjx;V#I!s7%G~U{vIq&lX6^&>~TA3IBa2R
zcTjcd#uQOCSz_C7iqhIb)lE^F0i14$LnT$>Eip=JoVX_%$3>ECs;
zhoovzBaN0?EjkKWu(ewBl;q1yUI>q>MQ2GiuMr(dfrtJ|kTdkJ5xWT$kW?d%A{)IV
zbf-paCE2}Ub{D)6c80*(=D{Ot5nA|w=Pso;Y^@btNsJF#cx1f70
zE+bu5OQ_+o=t*{cu0Qp8C?r0?Ay;q2#QN*2H>#lWNpo_O&8Q=>&DDG6hC;+su`5Yr
z?GQNhRP-X+Jl59F%~ONNGaTUiEs}1_Gtrye+$OaxpNm1HW4>g!GT#ceK1VxMfuy7L
zVoy@POVUN{HiNSdusk-@qtiXVkPIHzi@k}@KK-zL(OjANJ1P^umXuY$i@u~&F}8L3
zv|=?(e1Rj^A7mlG=@()zvg(Mw@JJX0|ABndclwg=`oVtIK3t~1QWgeIFJ<*3D4^t}
z81nxXW7zwp!QZCPSk@s?I1U~D6juuOpx{q&KIw7_3jY*?VfZV|PS#Ic_zx4Vo|6Q<
z`*{@vzeX?4Tw)+d?{ZlQUojB!Uv#|>Xt7GA%`KbFBv>Xig+8s&7uM<^?Jw*VaRb|-
z@Z>MdlHeXIuS5YIkbHNE4q)#uuLP1=TO9
zm+!b)^$^;G=jft2)K(we)prF1a&$Na9%s_kfdtd>PO6pEBH1B~32bC1p-q(zCemrZq0L
z6?nFx9}5laXAD@{Va3FM#`Wv$ur69>8w3?Nw8cX3TnEX{bQl!0#jW#X0~g%wvEx8J
z7rwQpf#l#OF1X|f<}kzooo>wGqzW)ew;ZUCkObC_be&KMg^o0bRBU1TSjcgreaWnC
zTp&_LH-&`2?AHfOA
zyPz|_o#rIs43J?5>-Kamg@Lz*KLDLW5p%(MDnznli@Y!BaYLJY^miW!4o&;H@CU)hbIjbiow&1rjgrs
z*@hVa<-MqBt~c(A0d*|^itB{-eEkl3qeGStjCHNR-3PZ=<0Cotq2Z+IiJa64tstfs
z?Ll1sFyKJ);w2YO22v4DG~xj@$rq2hHLw4UU%$p2KKI3Fa^L)2m(Uv*zt!9SfmOMF
z7|obQV^u3&K)d`fn$sp=9f6^@IAGHUPsn>jhNBgv4Lj%X@~o6$bCEP}vZa*B56;P9T#%m6(9xDJdk`jGkk41zh-^dlPRIuz63Fu)kAVEq8h
z^!H4s86?xC4B}V|BZpx)2f__HC44^&oo|Yi5x?QMpGHM-a&iP7BqxXCo_iQ=AZmyh
zfu~A`kp`l}BTX2Io9y&h&dNv9LBw-B7bcCuC&bS&2Eh_ujlw$ZAIC_4m_M4j3u~cp
zG+j*oy_m5OXupJ5!|X8_$)yxVg5iWYwS|ZbqjUyjK=N4H8`foDh4vqd%Y6-!#?qN&
z?&qwNH&~3rqL{fEHpJ3EupdV?AFzAf-!G9mqsyLl8#52<{-~FJhbV}T#rW-DeJoum++sgbFd+_?>DB?J4~2_NIDME4!4q+pRvl%6
zH@JU?>65;={4fz6oL^?Z6n-kha{F;1ma_LG)Wx1;iS>f7PvX8^Hwi=SaE_Cta~Ne;
ztLAIJ^fHihkE_@>JeWEeji0v=d$_^KktYTl4fLCWE-ra)AQUb>N7qdAaQZV-@F+h5
zwNq%K@EV3qrE^KNDGZy9&TKbT@Ye(BW0S=+Op1q!t5&KMYKWgkyOLEc#1w9*6m6ht
z8qS|(XD~E{N;`!)M8_kJbueHG3+qH%I1`Wa<7-PiolP#eGOaIUPNxpioCVWy&QI_N
znTM6yFrB)RYJX-D1XE|=drYSwF02Sr*h1Y59REx(CqrlA_RA0D!l{{f-2FMQSu+TO
z(#=9!MFc0Y5sKzz${nnbKOnOPa|5T@)LV#wp|k0Ha(fhG!4N!JVG8OwILzU3+{$vi
zLIo*|Op4*;`xu1=o-uNF0w*D{3J2z>7Bc4Is<)5h_~u-^v6p~$9=>MVO=Z!Bz}b1U
zx6lcM1e!#`W^lXW1bp+_Jj);`p<5zu_@47*WbHhRZ+{|As!ZZIE{PK@_$1+^jSD!=
zPQnBC?MG0OjI)^~W4?4tna~#smMWC&GZ7xMDGK=(xdFw=c$iFG&h6&T$3y?O6;5~p;?6Q7UI>wG0Q-dkdlQf
z6!HSk}?#Cb6m&7dvK@h`^HGh-_g0ztC`
zcgUSDxDd1#%T?MOOO6^9#H^#tzzdEZRapP;&j;hnxW&+==tv0{a*yGnk9r_lL+i5&
zE7`YC@vLNwdNstJRoFm83WivFjt|lEJQnpaM$TU{3~B}+EW-=&MVP#deni}_wG8md
zHH8Z=tvwY+PD4eD(X$GLCG1^}W7b#kF?H1n&HoPN^?h!Uw1T$&$9uf-m$)Gh45n(R
zS%IM+kdvV+@%;Vfsli4Cr&nUSi(blzM=BniE8lT)@twl#A5N8|Qb%Dfc&5=lK-2I{
zKFQiiWQHwRr{f2cW41~&Se%A?>tz}qDWRZEr*lZ=DyHoXhtuglKkD@|dfY77#7w>5
zWF}r!95SdIIU^@4GO*g?a%DD;7>U{}BL$n4YB;+AeK6amlpiGxTMbrT^FR}r)Khx}EzaJIXc&>yTmragp?Q2a4ot};KS-GxThUOv=yZ
z<>$Z}n6#SmKN3kF&BmRVqGNx{0mT}0vRDRYufaoW_{A2Bsx{P0*bdg8(uE}TdJF&c
zIy%X|CxY(_qvUmY(PEVPLTS&9rhSHylr+3owdcie*J#vL)%aeog5IBFZ1D8iREkn7bCO
zM_C$(ITTysUj=WhZGL>S8YX#KruT#3b!hy!S=hM_>%YK;Ypij4@g{4
z9-qt|+Hf{{10G~M?YVGj1D>E>PE6etF&QT}
z;SkQD+~z%Q4TEyfdMkd;WL*&llb3zD5Zwj5>n
zGzmX>39k*b{9cCDaIpl1?sK1_RAh_qZ;yCB=!zlAb
znsEMBd_33U*HHtxvlT~PHBUw==b;M|wqaMtL>d0iHk|O^$p%8fYdhZOe}u`~=}4(q
zrTF|)pd=sjYn_h+Y-|=DrC{Ys7th6MqI~1dgyMXfOqOP_P5}_N14D?x2PhNH?!XI&
z8wdq-22rnv&_cY36%?RByA4e62eU%jORxuBA)Q6~Y+}p{D$$D6Gz%U%7|D>G=*aY3
zuKs2xs^@Lz!m;hBZeN7z$vdE=2&-{r5teoW6c^D+!ey}DMH7TFWQ%
delta 8561
zcmb7JcUV+M*XPdeon4l;2&gngv7+>%Qlu#Yg4m4#QP7AQg(bFNU9f=_UEwGwf;I8g
zh$W&i8ci|AmY8P20vfUR5^H$R+=1(^d7k%sKL2p%_d93KoO5R8%-rGPai>eiovixy
zRw(Vwh?&}~?n{TARu2RP{u=6qPSUR@Im>=`s}$|Iz*l)$BASTi60y4l9Ze)=fqIx$
zaSe5EG~b{w$HuUf=3B4$84NB#vptvY9hX=ibZJs`Gq=wmK5vZonDljr250ntv-(zm
zV^q|)-BM}}+<3IfYn`qfZS5@h>vqh(H+gvVq&?#A>-%4eT=$|)Wn%B5yW?GR6?w;xE*j*%scp#@m&0Qh
z+Rt{J@;t6$Pg?(9OID}*^n3N{d7satUCz4ym2qK8JMp*j?aQ(}0~bu`+tuK7^zFeJ
zFLG0R(&4THTBfGO5AD)npIOQi*Xw^=whdpc9opa7v-fZIYfI_h*)4p+(;anXkJVEm
zo6JvJak*VT`yR6jkNW=n;_H(gk7eYhj`Zo2-Q=GN!QQ`ix2?Ku2mh@3e-s{>6!T30`Ml8mV<)Gx;mL(E~V~My;wI){(eYk}y=Q
z80aUp48e*sv8)AXBNBWH6xynNa}?XUNYac0ic(KWiUCDJ)=X+)!PYtZ$HKrYI5|q1
zIYg;kA+_`?m9bLBt4?fCu4@0Tcljx$c~#{J<(v@4889)BYAIS7;xAZNtx$QS2*;GB1jWQsdA?vA@)?yCfz^jr7LG&P!q`+k}vGS&VzP38CV$80E(H
zq)lk%)SZ@QW>2Ec%-Ztllm6eEd(YLNZqXh*u81C_*2D0|!x6Hsh?bK5Syx17As@~n0ybMyE!bTZok(35Bzt!5
z+gl*v3VCvkR;HO**G4b!w?1-sn87zW7%p7J7{6rg5Qw}c+DndjJi{?Zg(KM=c3(q<
zL%i+!m)Mm|ie$XuRHQY$e2MYR?`a%gPEVXzktKCpn`I99c~om?mTl~pCYwp2`z`eA^hBwB=oa=$oFcXTZsUBoOq1H5-xh;}
zV^DKjoJhQK8D&_IYYoR{p*K74pf}lbBt46}=*`}FhBNc}Lg8KXrbE8Ozq%{>l1+=T
z9R}m>iC&~&1-63?mshCa;=P7x)oG|RaVVAq+ENjE-A4nucX-!*(T~`!VsdbMfN4Ka
z(x7Y2lBYG?T4NmE#Wf9571%r!Lx_7>qh-U)G8-s)h}O-^8CU#B3?z>?%Ho7g8qhyN
zvG0~f4(>d{Yy@s?#D-;CZQ$4~Q44vGF?`*&Mgd%UEC!J_m5tait5O50Pte%oU5tb8
zpWtBg_Db!*rx;Pueo6QCehqAYih5rjkYskxj8>kYgvw`Pc&GohmZMD8aw_YPB^&~`
z=i)4K;~UudOpJzW&oTEMjxf+2`u&Zg=O5$XI|hC~E&+q*2{nYhKwo}5&4@7A`~v$$
z{ECis0~=i7Zl)#P%&fb~;1AA&@J5V)*)P#X?+YB~Dkp9GdQl15dW1Y>{@v^b4EV@Snh}CNu8^*sj2c1AYh-!bO3GlGSFAW=s7*
zrx5I61fl-KT_H1WaD!lnVWP}5g~Os?2^k8cSF2>&27Xu2K;mXDF^0Z%RuHeGUSy6X
zSNX*fPu%@V?38cIRdi)aa2CuH%GzB=Dj6VoP(Qix+_!g2e%f28eWNL8wjhMD-8boGzai&mByAv;e
zBe8`!GPi?_n20WDd6_v{o)j+ADtKm&!S{_~Ojj7Dp<1bIOEu-jFvf<%Ne$%(RyC_M
zQK_7*PsJd^EzsVkcT96QI|OUnuNFAll_QK411+g9S^p7d-h6~Pemjy{{%b$PH7;5F
zt&hwf&GqpD6ht1p1D3Gf3g@F{tSq*KcGlE~)O;*67O>u$dI?Ez#+s)8Yn&b?5S_QDKi9H_`Dmn
zg#k^dBbhjh)BBsyFk+|YAV)7WgAeU+jOn>DV+TJpq0Qj69cJuQ9;f~12u&c+785y_
zZJSneIeo_-(-1nJgG>jU;JgLk+mt54YX=%2Xu+o`ohQ6uKSRK(8E&f4i@3ayfu>7j
zK*6y&t~|eGVCzi#LD@2NzOp$wc7Hi%JRPw^tbv0)jx-eGedI_NkoqFVr9rh5j+hNv
zXSz(d4CT(Wh+HUv3THYDR=Ut=GHwkAL9VnbaVX>9lq>Zl>h%l^fobanCwLHqLC$P}
zL5|-5zJ9b9)U=?HXtzM8(mYO=T8vZnIzwdxOzZYd2I-e#RAizQgjb
z--@C-NGCK#W7nr%i}x^03p1O=es-&JC)-VBB80c5A!OtZBl@Qu
zn96glF_oH~Mzn4x(j(lF-dtrwTkaEFxf}EMo4Ap)-$?7qe!(4Z&N4b4
zmR%A=C_9Ub=9nj%%{ymA-#sT-Kp!uppPo0OQ!n85dgX(m-}1uHUsQ8hk~f<5xFjpu
zK%F=B5H5hN51mgQT;YO&HMsAwvj-`y<&4f3%gGnlIC$<$y9qnN$B*iTzhI{y%_F^T
zuuUEVx?8wMiu^IPHn(KPoh?ZZp&rr#C=j#zTo4gRdl8SPvX%<|48#KV^ciRL?nH!#
zF9a0~55j`K@P#Suz;>bsfmgl!Gn77=AR=24i)ZYRMAhMn@-NTof#wxI#_@x{%?_!6|2A1Ll$F!WkFN=p%7`uV^6yCA?`tG`xTW
zxS3dN?`EX6Bnr)*X=TKOv?lo3AC0zKx^ucX8g(Mtz@r#UMMQg?OxGUNU(iFp9yFhv
zZzl`vAh;*?^X|ZzT|Kd%e@6~VI%0qkF(`KL#F>Sia3HC|1$QRK7e-Hb7(;VOh9@8X
zP%li{LN5klpj#|nMp}7uu)~k2;Hy|H`Jed6j2aT-XgAWwPiC6(#6FF~nW*rWX%!5M
z$ED&DXvEaU<9fdr#F^o}X*e0!je|M{CI*8r5O?vpVA2Hc4a6;JNI;|7P)@&0z%3aW
z#zAf(dYTl@L46_?>*@#&A|i7oBL2FEO$rx;EwGS&83CW!~)8aFi{hGa7G`4
zv+dp&H-mGG%(%e*zNljrEB8`CPAp~=X)O%shi+BIaoPTU=!sbZ2VMH3Cxa6i=ndcW
z$4uSn!$1|r(vH*@0jLro-tMTWE~68-p4t@
zym^qEhcYd~$%k03ZdPDAe@Mp#R|PfcG)s61eMi#qBuNGRMxtYDRSHY#@-BTSSdPLx
z`KX!54-Su_Du`W){XZFn(=qQI-HYTmGt#-(Okn|uAL00^PBKlwa9y;AA3nmNheORr
zbR7A)BM6x^2;4jr)=)UQ;WK32XdH6^mVrzf1}{gWm6`^So`GK(ZE*85$&4_CJ@hI<
zZ=PqM$sfUX4Aqf!kz9Is3|6$idvTBytFVIbv1myd&zY2Xg$7CiI~OG=jL(96+{4Uz
zO@hLlKje%}GEqoL;tET~Vded6kOYc9#@+JeP%g0gP@#sLk8!ZHWX|kQ#+2e$vInV4
z;Y{~o3McM+?s&|kTPmk7j7OWhMsScg0e4;VksRnoDr_Jo6aBqD0fTK*oJn
z$)sc~*J+!Dr=#x#4%TF04H9)64AWu!+q1B@zYd41pUCOOI;^E{C&TiISR~Zhn6Stk
z4yH3uJ&l9=*=Tpv3=R&?P}suMPcW8iGi62vH$TCmK4vy&QYTWo|D!M@L*Ya^LDm3dICef|}9=VgSoL$RjTogp$Lp6Lo8Ap9uz?t$z3K6`g;P#4MBr~?~
zDqU_eV@sDag-B>Q6}96^8e*S{*Vj3v41|Kkrzp9;g@e$~aPi_Rq!y;`GqE#n
zAEZsk9kLJU_cyBaK9GEXDTKfWIq2xF2JlS|P6YjlOHzJPG=aEjD871}GnLb@W}H0D
zLF?%>OgIfArsJ%7|H-&WSoCM(QrUFg#GO^=d8hm5ar11Mfi@psW=t?x&E%i?l2b2l
z@*Rk;`40d3PBwiU>w8O9HHRxRaS2o!GfA_s$bNBO)=|UvvoLQPpBXV;voW06Z#i@F
zt>WJmt#USX7AAm?o^}IKj~Cq|tgREqK!u)8AW?0>SC3B>vAMV;=FVlHi;tIXjPAV$
z8I$+wK$A0M(n{9L4;=ESH~Fyve42;5X?QW0Y%5l3rKev=Uad5~7WIKvbFePGDdl?K
z%t0p(m2qIPPTA!D@oN4Je(*9cbeoiFSUVS|#{s{38yGvVo`>Jc?we%B0p`v_o%(l-
z9Xw+?{ddVa<}iFdMv=ZpX4Fu%N7;mxPBlE3Pg|40U&}HTBrL$`xq#n-Y}79nU|0Q7
z8Cb&kqe@Huat_Co#x-!^F|M^DpE|Hp0N0U=Z~|->;@Tz!=;h7^Ft&hp5e7g-0nHT3
zK)aAmBKmV&@6U7Sm$VxLpO_f$@PsSOdZKjX7mtyPuqN$#&FQZe(FkJx4+Fsvv={@p
zRnI^mlrE-$!VoyKm=1XNhUI~$7GQMjPfIXo3HmnAn7N`7)$ILW3v?-#O}vgQMIQ%S
z%bhIQ>!78Itse{&*y1FswOq{pECpf9(B?g3Cc;*z?5q(4J_UyO=A37mF8r}2K?7qI4a9Kg5`eo^>vh-kbu6Tx>C<$t)7yiQ1lw6&;v
zZWZlBHa38W)i?<`XR2U+)8jOJ7;jPtOO<7$8bQq
z>(T3sVlLjc9>tk!IcT~8$4Xzv!H#vPyLmB=wQ>W_a8@~|S6)XKo*^Ur0@{rC$QucjbQpe3a9m
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index aef17cfa..6962e78e 100644
--- a/modules/_bundled/sirsoft-page/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-page/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.0] - 2026-08-22
+
+### Added
+
+- 페이지를 공유할 때의 미리보기 이미지(og:image)가 본문의 첫 이미지로 채워집니다. 이전에는 페이지에 공유 이미지를 공급할 방법이 전혀 없어 미리보기 카드가 항상 이미지 없이 표시되었습니다. 외부 주소의 이미지는 사용하지 않으며, 기존 페이지는 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
+
+### Fixed
+
+- 페이지 공유 미리보기의 설명(og:description)이 항상 비어 있던 문제를 수정했습니다. 이제 페이지 SEO 설정의 설명이 사용됩니다.
+
## [1.0.3] - 2026-08-19
### Security
diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json
index 0f070e06..b7e3aed3 100644
--- a/modules/_bundled/sirsoft-page/composer.json
+++ b/modules/_bundled/sirsoft-page/composer.json
@@ -2,7 +2,7 @@
"name": "modules/sirsoft-page",
"description": "Page module for Gnuboard7",
"type": "library",
- "version": "1.0.3",
+ "version": "1.1.0",
"license": "MIT",
"autoload": {
"psr-4": {
diff --git a/modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php b/modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php
new file mode 100644
index 00000000..65cf6917
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/database/migrations/2026_08_22_000001_add_content_thumbnail_url_to_pages_table.php
@@ -0,0 +1,44 @@
+string('content_thumbnail_url', 1000)
+ ->nullable()
+ ->after('content_mode')
+ ->comment('본문 첫 내부 이미지 URL 캐시 — 페이지 og:image 폴백');
+ }
+ });
+ }
+
+ /**
+ * 마이그레이션 롤백
+ */
+ public function down(): void
+ {
+ if (Schema::hasTable('pages')) {
+ $columns = Schema::getColumnListing('pages');
+
+ Schema::table('pages', function (Blueprint $table) use ($columns) {
+ if (in_array('content_thumbnail_url', $columns)) {
+ $table->dropColumn('content_thumbnail_url');
+ }
+ });
+ }
+ }
+};
diff --git a/modules/_bundled/sirsoft-page/docs/api/pages.md b/modules/_bundled/sirsoft-page/docs/api/pages.md
index d8dcd6ad..7d172551 100644
--- a/modules/_bundled/sirsoft-page/docs/api/pages.md
+++ b/modules/_bundled/sirsoft-page/docs/api/pages.md
@@ -1271,6 +1271,7 @@ _단건 응답: `data` 객체의 필드._
| title | string | `이용약관` | 제목 |
| content | string | `=7.0.7",
+ "g7_version": ">=7.0.9",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json
index d29325a4..a786b89a 100644
--- a/modules/_bundled/sirsoft-page/package-lock.json
+++ b/modules/_bundled/sirsoft-page/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.0.3",
+ "version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@g7/sirsoft-page",
- "version": "1.0.3",
+ "version": "1.1.0",
"devDependencies": {
"jsdom": "^27.4.0",
"typescript": "^5.3.3",
diff --git a/modules/_bundled/sirsoft-page/package.json b/modules/_bundled/sirsoft-page/package.json
index bdc4a069..74bea862 100644
--- a/modules/_bundled/sirsoft-page/package.json
+++ b/modules/_bundled/sirsoft-page/package.json
@@ -1,6 +1,6 @@
{
"name": "@g7/sirsoft-page",
- "version": "1.0.3",
+ "version": "1.1.0",
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
"private": true,
"type": "module",
diff --git a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php
index be2e611c..39f6658d 100644
--- a/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php
+++ b/modules/_bundled/sirsoft-page/src/Http/Resources/PublicPageResource.php
@@ -53,6 +53,8 @@ class PublicPageResource extends BaseApiResource
? ($this->content[$locale] ?? $this->content[$fallback] ?? (! empty($this->content) ? array_values($this->content)[0] : ''))
: (string) ($this->content ?? ''),
'content_mode' => $this->content_mode ?? 'html',
+ // 본문 첫 내부 이미지 URL 캐시 — 페이지 표시 레이아웃의 og:image 가 소비 (공개 #22)
+ 'content_thumbnail_url' => $this->content_thumbnail_url,
'is_preview' => $this->preview,
'published_at' => $this->published_at
? $this->formatDateTimeStringForUser($this->published_at)
diff --git a/modules/_bundled/sirsoft-page/src/Models/Page.php b/modules/_bundled/sirsoft-page/src/Models/Page.php
index f00a4d5b..9fa2d960 100644
--- a/modules/_bundled/sirsoft-page/src/Models/Page.php
+++ b/modules/_bundled/sirsoft-page/src/Models/Page.php
@@ -2,10 +2,12 @@
namespace Modules\Sirsoft\Page\Models;
+use App\Casts\AsUnicodeJson;
use App\Extension\HookManager;
use App\Models\User;
-use App\Casts\AsUnicodeJson;
use App\Search\Contracts\FulltextSearchable;
+use App\Support\HtmlImageExtractor;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -52,6 +54,7 @@ class Page extends Model implements FulltextSearchable
'title',
'content',
'content_mode',
+ 'content_thumbnail_url',
'published',
'published_at',
'seo_meta',
@@ -77,6 +80,65 @@ class Page extends Model implements FulltextSearchable
];
}
+ /**
+ * 모델 이벤트 등록
+ *
+ * 본문 첫 내부 이미지 URL 캐시(content_thumbnail_url)는 저장 시점에만 계산한다.
+ * 모델 saving 이벤트에 두는 이유: PageService::restoreVersion() 이 서비스
+ * create/update 를 우회해 Repository 를 직접 호출하므로, 버전 롤백 시에도 캐시가
+ * 자동 재계산되는 유일 지점이 모델 이벤트다 (공개 이슈 #22 동종 — board 와 동형).
+ */
+ protected static function booted(): void
+ {
+ static::saving(function (Page $page) {
+ if ($page->exists && ! $page->isDirty('content') && ! $page->isDirty('content_mode')) {
+ return;
+ }
+
+ // text 모드 본문은 이스케이프 렌더(이미지 미표시) — 캐시하지 않는다
+ if ($page->content_mode !== 'html') {
+ $page->content_thumbnail_url = null;
+
+ return;
+ }
+
+ // content 는 다국어 JSON — 기본 로케일 우선, 없으면 배열 순서대로
+ // 첫 내부 이미지가 나올 때까지 시도 (레거시 평문 문자열도 수용)
+ $extracted = null;
+ $candidates = [];
+ $content = $page->content;
+ $htmls = is_array($content)
+ ? array_values(array_filter(
+ [$content[config('app.locale')] ?? null, ...array_values($content)],
+ fn ($html) => is_string($html) && $html !== ''
+ ))
+ : (is_string($content) && $content !== '' ? [$content] : []);
+
+ foreach ($htmls as $html) {
+ $candidates = array_merge($candidates, HtmlImageExtractor::candidates($html));
+ $extracted ??= HtmlImageExtractor::firstInternal($html);
+
+ if ($extracted !== null) {
+ break;
+ }
+ }
+
+ // 확장이 후보를 대체(CDN prefix 승격 등)하거나 차단(null)할 수 있는 필터 훅.
+ // 특정 에디터 확장에 의존하지 않는다 — 페이로드는 일반 HTML 파싱 결과뿐이다.
+ $value = HookManager::applyFilters(
+ 'sirsoft-page.page.filter_content_thumbnail',
+ $extracted,
+ $page,
+ $candidates
+ );
+
+ // 필터 반환값 방어 — 비문자열/빈 값/컬럼 상한 초과는 null(후보 없음)로 강등
+ $page->content_thumbnail_url = is_string($value) && $value !== '' && mb_strlen($value) <= 1000
+ ? $value
+ : null;
+ });
+ }
+
/**
* 생성자와의 관계를 정의합니다.
*
@@ -120,8 +182,8 @@ class Page extends Model implements FulltextSearchable
/**
* 발행된 페이지만 조회하는 스코프
*
- * @param \Illuminate\Database\Eloquent\Builder $query
- * @return \Illuminate\Database\Eloquent\Builder
+ * @param Builder $query
+ * @return Builder
*/
public function scopePublished($query)
{
diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php
new file mode 100644
index 00000000..9a817cbe
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php
@@ -0,0 +1,126 @@
+ 'ko']);
+ }
+
+ protected function tearDown(): void
+ {
+ DB::table('pages')->where('slug', 'like', 'backfill-%')->delete();
+ parent::tearDown();
+ }
+
+ /**
+ * 모델 이벤트를 우회해 레거시 상태 페이지 행을 만듭니다.
+ *
+ * @param array $attributes 덮어쓸 속성
+ * @return int 페이지 ID
+ */
+ private function createLegacyRow(array $attributes = []): int
+ {
+ return DB::table('pages')->insertGetId(array_merge([
+ 'slug' => 'backfill-'.uniqid(),
+ 'title' => json_encode(['ko' => '레거시 페이지'], JSON_UNESCAPED_UNICODE),
+ 'content' => json_encode(['ko' => '
본문
'], JSON_UNESCAPED_UNICODE),
+ 'content_mode' => 'html',
+ 'content_thumbnail_url' => null,
+ 'published' => 1,
+ 'published_at' => now(),
+ 'current_version' => 1,
+ 'created_at' => now(),
+ 'updated_at' => now(),
+ ], $attributes));
+ }
+
+ private function runBackfill(): void
+ {
+ $context = new UpgradeContext('1.0.3', '1.1.0', '1.1.0', 'extension-upgrade');
+
+ (new Upgrade_1_1_0)->run($context);
+ }
+
+ /**
+ * @scenario backfill=legacy_row
+ *
+ * @effects backfill_fills_legacy_pages
+ */
+ #[Test]
+ public function legacy_rows_are_backfilled_and_gated(): void
+ {
+ $legacy = $this->createLegacyRow([
+ 'content' => json_encode([
+ 'ko' => '
',
+ ], JSON_UNESCAPED_UNICODE),
+ ]);
+ $textMode = $this->createLegacyRow([
+ 'content_mode' => 'text',
+ 'content' => json_encode(['ko' => '텍스트
'], JSON_UNESCAPED_UNICODE),
+ ]);
+ $filled = $this->createLegacyRow([
+ 'content' => json_encode(['ko' => '
'], JSON_UNESCAPED_UNICODE),
+ 'content_thumbnail_url' => '/storage/pages/kept.jpg',
+ ]);
+
+ $this->runBackfill();
+
+ $this->assertSame('/storage/pages/legacy.jpg', DB::table('pages')->where('id', $legacy)->value('content_thumbnail_url'));
+ $this->assertNull(DB::table('pages')->where('id', $textMode)->value('content_thumbnail_url'));
+ $this->assertSame('/storage/pages/kept.jpg', DB::table('pages')->where('id', $filled)->value('content_thumbnail_url'));
+ }
+
+ /**
+ * @scenario backfill=idempotent
+ *
+ * @effects backfill_idempotent_second_run
+ */
+ #[Test]
+ public function second_run_is_idempotent(): void
+ {
+ $this->createLegacyRow([
+ 'content' => json_encode(['ko' => '
'], JSON_UNESCAPED_UNICODE),
+ ]);
+ $this->createLegacyRow([
+ 'content' => json_encode(['ko' => '이미지 없음
'], JSON_UNESCAPED_UNICODE),
+ ]);
+
+ $this->runBackfill();
+ $first = $this->snapshot();
+
+ $this->runBackfill();
+ $second = $this->snapshot();
+
+ $this->assertSame($first, $second, '재실행 결과가 동일해야 합니다.');
+ }
+
+ /**
+ * @return array 스냅샷
+ */
+ private function snapshot(): array
+ {
+ return DB::table('pages')
+ ->where('slug', 'like', 'backfill-%')
+ ->orderBy('id')
+ ->get(['id', 'content_thumbnail_url'])
+ ->map(fn ($row) => ['id' => (int) $row->id, 'content_thumbnail_url' => $row->content_thumbnail_url])
+ ->all();
+ }
+}
diff --git a/modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php b/modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php
new file mode 100644
index 00000000..b9c53d6b
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php
@@ -0,0 +1,71 @@
+forceDelete();
+ parent::tearDown();
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, locale_content=default_locale
+ *
+ * @effects public_resource_emits_thumbnail_key
+ */
+ #[Test]
+ public function public_response_emits_content_thumbnail_url(): void
+ {
+ config(['app.locale' => 'ko']);
+
+ Page::factory()->create([
+ 'slug' => 'thumb-emit-filled',
+ 'published' => true,
+ 'published_at' => now(),
+ 'content' => ['ko' => '본문
'],
+ ]);
+
+ $response = $this->getJson('/api/modules/sirsoft-page/pages/thumb-emit-filled');
+
+ $response->assertStatus(200);
+ $this->assertSame('/storage/pages/og-image.jpg', $response->json('data.content_thumbnail_url'));
+ }
+
+ /**
+ * @scenario image_source=none, locale_content=default_locale
+ *
+ * @effects public_resource_emits_thumbnail_key
+ */
+ #[Test]
+ public function public_response_emits_null_when_no_image(): void
+ {
+ Page::factory()->create([
+ 'slug' => 'thumb-emit-empty',
+ 'published' => true,
+ 'published_at' => now(),
+ 'content' => ['ko' => '이미지 없는 본문
'],
+ ]);
+
+ $response = $this->getJson('/api/modules/sirsoft-page/pages/thumb-emit-empty');
+
+ $response->assertStatus(200);
+ $this->assertArrayHasKey('content_thumbnail_url', $response->json('data'));
+ $this->assertNull($response->json('data.content_thumbnail_url'));
+ }
+}
diff --git a/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts b/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts
new file mode 100644
index 00000000..c61eceee
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts
@@ -0,0 +1,109 @@
+/**
+ * 페이지 og:image 본문 첫 이미지 공급 — 봇 경로 실측 (공개 이슈 #22, 내부 #610).
+ *
+ * 페이지는 og:image 를 공급할 경로가 전무했다. 본문에 이미지를 넣어 저장하면
+ * 봇 경로(`?_escaped_fragment_=`)의 서버 렌더 HTML 에 og:image 메타가 절대 URL 로
+ * 실리는지, 이미지 없는 페이지는 og:image 가 출력되지 않는지(사이트 기본값 미설정 시)
+ * 확인한다. og:description 死키 정렬(#11)도 함께 실측한다.
+ *
+ * 단위/통합: PageContentThumbnailTest(saving 추출), PageContentThumbnailResourceTest
+ * (Resource 방출)가 담당하고, 이 spec 은 봇 경로 meta 렌더를 담당한다.
+ *
+ * 전용 페이지(slug: e2e-og-image-*)를 spec 안에서 생성/삭제하므로 시드 의존이 없다.
+ *
+ * @scenario page-og-image
+ * @axes image_source=content_internal_only image_source=none
+ * @effects bot_page_renders_og_image_meta,
+ * content_image_fills_page_og
+ */
+import { test as base, expect } from '@playwright/test';
+import { issueToken } from '../../../../../../../tests/Playwright/fixtures/auth';
+
+const API = '/api/modules/sirsoft-page';
+
+type PageAuthFixtures = { pageManageToken: string };
+
+const test = base.extend({
+ pageManageToken: async ({}, use) => {
+ await use(issueToken(
+ 'sirsoft-page.pages.create',
+ 'sirsoft-page.pages.read',
+ 'sirsoft-page.pages.update',
+ 'sirsoft-page.pages.delete',
+ ));
+ },
+});
+
+/** API 컨텍스트로 호출한다 (spec 전용 — 페이지 방문 불필요한 셋업/정리용). */
+async function api(
+ request: import('@playwright/test').APIRequestContext,
+ bearer: string,
+ method: 'get' | 'post' | 'delete',
+ path: string,
+ data?: unknown,
+): Promise<{ status: number; body: any }> {
+ const response = await request[method](path, {
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
+ data: data as any,
+ });
+ let body: any = null;
+ try {
+ body = await response.json();
+ } catch {
+ /* 비 JSON 응답 허용 */
+ }
+ return { status: response.status(), body };
+}
+
+test.describe('페이지 og:image 본문 이미지 공급 (공개 #22)', () => {
+ test('본문 이미지 페이지의 봇 경로 HTML 에 og:image 절대 URL 이 실린다', async ({ request, pageManageToken }) => {
+ const slug = 'e2e-og-image-filled';
+ await api(request, pageManageToken, 'delete', `${API}/admin/pages/by-slug/${slug}`).catch(() => null);
+
+ const created = await api(request, pageManageToken, 'post', `${API}/admin/pages`, {
+ slug,
+ title: { ko: '[검수] og 이미지 페이지' },
+ content: { ko: '본문
' },
+ content_mode: 'html',
+ published: true,
+ seo_meta: { description: 'og 설명 검증용' },
+ });
+ expect([200, 201], `page create failed: ${JSON.stringify(created.body).slice(0, 300)}`).toContain(created.status);
+ const pageId = created.body?.data?.id;
+
+ // 공개 API 가 캐시를 방출하는지 (레이아웃 og.image 의 데이터 근원)
+ const pub = await api(request, pageManageToken, 'get', `${API}/pages/${slug}`);
+ expect(pub.body?.data?.content_thumbnail_url).toBe('/favicon.ico');
+
+ // 봇 경로 서버 렌더 — og:image 절대 URL + og:description 정렬(#11)
+ const bot = await request.get(`/page/${slug}?_escaped_fragment_=`);
+ const html = await bot.text();
+ expect(html).toMatch(//);
+ expect(html).toContain('');
+
+ // 정리
+ const del = await api(request, pageManageToken, 'delete', `${API}/admin/pages/${pageId}`);
+ expect([200, 204]).toContain(del.status);
+ });
+
+ test('이미지 없는 페이지는 og:image 가 본문 폴백으로 채워지지 않는다', async ({ request, pageManageToken }) => {
+ const slug = 'e2e-og-image-empty';
+
+ const created = await api(request, pageManageToken, 'post', `${API}/admin/pages`, {
+ slug,
+ title: { ko: '[검수] og 이미지 없는 페이지' },
+ content: { ko: '이미지가 없는 본문입니다.
' },
+ content_mode: 'html',
+ published: true,
+ });
+ expect([200, 201]).toContain(created.status);
+ const pageId = created.body?.data?.id;
+
+ const pub = await api(request, pageManageToken, 'get', `${API}/pages/${slug}`);
+ expect(pub.body?.data?.content_thumbnail_url).toBeNull();
+
+ // 정리
+ const del = await api(request, pageManageToken, 'delete', `${API}/admin/pages/${pageId}`);
+ expect([200, 204]).toContain(del.status);
+ });
+});
diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php
new file mode 100644
index 00000000..56e571df
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php
@@ -0,0 +1,173 @@
+ 'ko']);
+ }
+
+ protected function tearDown(): void
+ {
+ HookManager::clearFilter(self::FILTER_HOOK);
+ parent::tearDown();
+ }
+
+ /**
+ * @param array $attributes 덮어쓸 속성
+ * @return Page 생성된 페이지
+ */
+ private function createPage(array $attributes = []): Page
+ {
+ return Page::factory()->create($attributes);
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, locale_content=default_locale
+ *
+ * @effects content_image_fills_page_og
+ */
+ #[Test]
+ public function saving_extracts_first_internal_image(): void
+ {
+ $page = $this->createPage([
+ 'content' => [
+ 'ko' => '본문
',
+ 'en' => '
',
+ ],
+ ]);
+
+ $this->assertSame('/storage/pages/ko-first.jpg', $page->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @scenario image_source=content_internal_only, locale_content=other_locale_only
+ *
+ * @effects other_locale_image_used_when_default_has_none
+ */
+ #[Test]
+ public function falls_back_to_other_locale(): void
+ {
+ $page = $this->createPage([
+ 'content' => [
+ 'ko' => '이미지 없음
',
+ 'en' => '
',
+ ],
+ ]);
+
+ $this->assertSame('/storage/pages/en-only.jpg', $page->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @scenario image_source=content_external_only, locale_content=default_locale
+ *
+ * @effects external_only_content_yields_null
+ */
+ #[Test]
+ public function external_only_content_yields_null(): void
+ {
+ $page = $this->createPage([
+ 'content' => ['ko' => '
'],
+ ]);
+
+ $this->assertNull($page->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * @scenario image_source=none, locale_content=default_locale
+ *
+ * @effects external_only_content_yields_null
+ */
+ #[Test]
+ public function text_mode_and_no_image_yield_null(): void
+ {
+ $noImage = $this->createPage(['content' => ['ko' => '이미지 없음
']]);
+ $textMode = $this->createPage([
+ 'content_mode' => 'text',
+ 'content' => ['ko' => '텍스트
마크업'],
+ ]);
+
+ $this->assertNull($noImage->fresh()->content_thumbnail_url);
+ $this->assertNull($textMode->fresh()->content_thumbnail_url, 'text 모드는 캐시하지 않아야 합니다.');
+ }
+
+ /**
+ * @effects recompute_on_content_change
+ */
+ #[Test]
+ public function content_change_recomputes_and_unrelated_update_does_not(): void
+ {
+ $page = $this->createPage([
+ 'content' => ['ko' => '
'],
+ ]);
+
+ $page->update(['content' => ['ko' => '
']]);
+ $this->assertSame('/storage/pages/new.jpg', $page->fresh()->content_thumbnail_url);
+
+ DB::table('pages')->where('id', $page->id)
+ ->update(['content_thumbnail_url' => '/storage/pages/manual.jpg']);
+
+ $page->fresh()->update(['published' => true, 'published_at' => now()]);
+
+ $this->assertSame(
+ '/storage/pages/manual.jpg',
+ $page->fresh()->content_thumbnail_url,
+ 'content 가 dirty 가 아니면 재계산하지 않아야 합니다.'
+ );
+ }
+
+ /**
+ * @effects filter_hook_can_override_or_block
+ */
+ #[Test]
+ public function filter_hook_can_override_or_block(): void
+ {
+ HookManager::addFilter(self::FILTER_HOOK, fn ($value) => null);
+
+ $blocked = $this->createPage([
+ 'content' => ['ko' => '
'],
+ ]);
+
+ $this->assertNull($blocked->fresh()->content_thumbnail_url);
+ }
+
+ /**
+ * 버전 롤백 경로(Repository 직접 update)에서도 캐시가 재계산되어야 합니다.
+ *
+ * @effects version_restore_recomputes_cache
+ */
+ #[Test]
+ public function model_level_update_recomputes_cache(): void
+ {
+ $page = $this->createPage([
+ 'content' => ['ko' => '
'],
+ ]);
+
+ // restoreVersion 은 Repository 를 직접 호출한다 — 모델 update 경로와 동일한
+ // saving 이벤트를 타므로 모델 레벨 update 로 등가 검증한다
+ Page::findOrFail($page->id)->update([
+ 'content' => ['ko' => '
'],
+ ]);
+
+ $this->assertSame('/storage/pages/v2-restored.jpg', $page->fresh()->content_thumbnail_url);
+ }
+}
diff --git a/modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml b/modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml
new file mode 100644
index 00000000..96f48411
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/tests/scenarios/page-og-image.yaml
@@ -0,0 +1,42 @@
+feature: 페이지 og:image 본문 첫 이미지 공급
+
+description: |
+ 공개 이슈 #22 동종 — 페이지는 og:image 를 공급할 경로가 전무했다(본문·첨부·수동
+ 어느 경로로도 불가). 저장 시 본문(다국어 JSON) 첫 내부 이미지 URL 을
+ pages.content_thumbnail_url 에 캐시하고, PublicPageResource 가 방출해 페이지 표시
+ 레이아웃(page/show.json)의 og.image / structured_data.image 가 소비한다.
+ 별건 #11(og:description 死키 excerpt)도 seo_meta.description 으로 정렬했다.
+
+axes:
+ image_source: [content_internal_only, content_external_only, none]
+ locale_content: [default_locale, other_locale_only]
+
+exclusions:
+ - { image_source: content_external_only, locale_content: other_locale_only, reason: "외부 제외 판정은 로케일 무관 (전 로케일 순회 후 null) — default_locale 대표 조합으로 검증" }
+ - { image_source: none, locale_content: other_locale_only, reason: "이미지 없음은 로케일 구분이 성립하지 않는다 (전 로케일 빈 결과)" }
+
+sub_flows:
+ - id: backfill
+ description: 1.1.0 업그레이드 스텝이 기존 페이지를 chunkById 백필 (다국어 JSON 파싱 동결 사본)
+ effects:
+ - backfill_fills_legacy_pages
+ - backfill_idempotent_second_run
+ - id: recompute
+ description: content/content_mode 변경 시에만 saving 이벤트가 재계산 (버전 롤백 경로 포함)
+ effects:
+ - recompute_on_content_change
+ - filter_hook_can_override_or_block
+ - version_restore_recomputes_cache
+
+effects:
+ - content_image_fills_page_og
+ - other_locale_image_used_when_default_has_none
+ - external_only_content_yields_null
+ - public_resource_emits_thumbnail_key
+ - bot_page_renders_og_image_meta
+
+test_files:
+ - modules/_bundled/sirsoft-page/tests/Unit/Models/PageContentThumbnailTest.php
+ - modules/_bundled/sirsoft-page/tests/Feature/User/PageContentThumbnailResourceTest.php
+ - modules/_bundled/sirsoft-page/tests/Feature/Upgrade/PageContentThumbnailBackfillTest.php
+ - modules/_bundled/sirsoft-page/tests/Playwright/specs/user/page-og-image.spec.ts
diff --git a/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php
new file mode 100644
index 00000000..6b31b712
--- /dev/null
+++ b/modules/_bundled/sirsoft-page/upgrades/Upgrade_1_1_0.php
@@ -0,0 +1,17 @@
+logger->warning('[page:1.1.0] pages.content_thumbnail_url 미존재 — 스킵');
+
+ return;
+ }
+
+ $filled = 0;
+ $noCandidate = 0;
+
+ // text 모드 본문은 이스케이프 렌더(이미지 미표시)이므로 html 모드만 대상
+ DB::table(self::PAGES_TABLE)
+ ->whereNull('content_thumbnail_url')
+ ->where('content_mode', 'html')
+ ->whereNotNull('content')
+ ->where('content', '!=', '')
+ ->orderBy('id')
+ ->select('id', 'content')
+ ->chunkById(200, function ($pages) use (&$filled, &$noCandidate) {
+ foreach ($pages as $page) {
+ $url = $this->firstInternalFromContent((string) $page->content);
+
+ if ($url === null) {
+ $noCandidate++;
+
+ continue;
+ }
+
+ DB::table(self::PAGES_TABLE)
+ ->where('id', $page->id)
+ ->update(['content_thumbnail_url' => $url]);
+
+ $filled++;
+ }
+ });
+
+ $context->logger->info("[page:1.1.0] 본문 썸네일 백필: 채움 {$filled} / 후보 없음 {$noCandidate}");
+ }
+
+ /**
+ * 다국어 JSON(또는 레거시 평문) 본문에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
+ *
+ * @param string $rawContent DB 원시 content 값
+ * @return string|null 첫 내부 이미지 URL (없으면 null)
+ */
+ private function firstInternalFromContent(string $rawContent): ?string
+ {
+ if (trim($rawContent) === '') {
+ return null;
+ }
+
+ $decoded = json_decode($rawContent, true);
+
+ $htmls = is_array($decoded)
+ ? array_values(array_filter(
+ [$decoded[config('app.locale')] ?? null, ...array_values($decoded)],
+ static fn ($html) => is_string($html) && $html !== ''
+ ))
+ : [$rawContent];
+
+ foreach ($htmls as $html) {
+ $url = $this->firstInternalImageUrl($html);
+
+ if ($url !== null) {
+ return $url;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 단일 HTML 에서 첫 내부 이미지 URL 을 추출합니다 (동결 사본).
+ *
+ * @param string $html 본문 HTML
+ * @return string|null 첫 내부 이미지 URL
+ */
+ private function firstInternalImageUrl(string $html): ?string
+ {
+ foreach ($this->imageSources($html) as $src) {
+ $resolved = $this->resolveInternal($src);
+
+ if ($resolved === null || mb_strlen($resolved) > self::MAX_URL_LENGTH) {
+ continue;
+ }
+
+ return $resolved;
+ }
+
+ return null;
+ }
+
+ /**
+ * HTML 에서 모든 img src 를 문서 순서로 수집합니다 (동결 사본).
+ *
+ * @param string $html 본문 HTML
+ * @return array src 목록
+ */
+ private function imageSources(string $html): array
+ {
+ if (trim($html) === '' || ! class_exists(\DOMDocument::class)) {
+ return [];
+ }
+
+ $document = new \DOMDocument('1.0', 'UTF-8');
+ $previous = libxml_use_internal_errors(true);
+
+ try {
+ $loaded = $document->loadHTML(
+ ''
+ .$html.'
',
+ LIBXML_NOERROR | LIBXML_NOWARNING
+ );
+
+ if (! $loaded) {
+ return [];
+ }
+
+ $sources = [];
+
+ foreach ($document->getElementsByTagName('img') as $img) {
+ if (! $img instanceof \DOMElement) {
+ continue;
+ }
+
+ $src = trim($img->getAttribute('src'));
+
+ if ($src !== '') {
+ $sources[] = $src;
+ }
+ }
+
+ return $sources;
+ } catch (\Throwable) {
+ return [];
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+ }
+
+ /**
+ * src 후보를 내부 URL 로 해석합니다 (동결 사본 — 정규화 규칙 포함).
+ *
+ * @param string $src img src 속성값
+ * @return string|null 내부 URL (외부/비허용 스킴이면 null)
+ */
+ private function resolveInternal(string $src): ?string
+ {
+ $trimmed = trim($src);
+
+ if ($trimmed === '') {
+ return null;
+ }
+
+ $normalized = $this->normalizeForOriginCheck($trimmed);
+
+ if ($normalized === '' || str_starts_with($normalized, '//')) {
+ return null;
+ }
+
+ if (str_starts_with($normalized, '/')) {
+ return $normalized;
+ }
+
+ $scheme = parse_url($normalized, PHP_URL_SCHEME);
+
+ if (is_string($scheme) && in_array(strtolower($scheme), ['http', 'https'], true)) {
+ $host = parse_url($normalized, PHP_URL_HOST);
+ $appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
+
+ if (is_string($host) && is_string($appHost) && strcasecmp($host, $appHost) === 0) {
+ $path = parse_url($normalized, PHP_URL_PATH);
+ $query = parse_url($normalized, PHP_URL_QUERY);
+
+ return (is_string($path) && $path !== '' ? $path : '/')
+ .(is_string($query) && $query !== '' ? '?'.$query : '');
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * origin 판정 전 브라우저 URL 파서 동형 정규화 (동결 사본).
+ *
+ * @param string $url 원본 URL
+ * @return string 정규화된 URL
+ */
+ private function normalizeForOriginCheck(string $url): string
+ {
+ $stripped = str_replace(["\t", "\n", "\r"], '', $url);
+ $slashed = str_replace('\\', '/', $stripped);
+
+ return preg_replace('#^([a-z][a-z0-9+.\-]*:)?/{2,}#i', '$1//', $slashed) ?? $slashed;
+ }
+}
diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
index 09626a51..fd74cf5d 100644
--- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.7] - 2026-08-22
+
+### Added
+
+- 환경설정 > SEO 탭에서 사이트 기본 공유 이미지(og:image)를 업로드할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 링크 미리보기 카드에 사용됩니다.
+
## [1.0.6] - 2026-08-22
### Added
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
index 3daac052..a0961cea 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json
@@ -1419,6 +1419,8 @@
"og_image_default_width": "OG Image Default Width (px)",
"og_image_default_height": "OG Image Default Height (px)",
"og_image_default_size_hint": "Default values when thumbnail size is unknown. Facebook recommends 1200×630, minimum 200×200.",
+ "og_image_default": "Default Share Image",
+ "og_image_default_desc": "Site-wide fallback image used when a page has no share image of its own. Shown in link preview cards.",
"twitter_default_card": "Twitter Card Default Type",
"twitter_default_card_hint": "Use summary_large_image for large image cards, summary for compact ones.",
"twitter_default_site": "Twitter Site Handle",
diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
index 613ffb2d..636c0507 100644
--- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
+++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json
@@ -1423,6 +1423,8 @@
"og_image_default_width": "OG 이미지 기본 가로 (px)",
"og_image_default_height": "OG 이미지 기본 세로 (px)",
"og_image_default_size_hint": "썸네일 실제 크기를 모르는 경우의 기본값입니다. 페이스북은 권장 1200×630, 최소 200×200.",
+ "og_image_default": "기본 공유 이미지",
+ "og_image_default_desc": "화면별 공유 이미지가 없을 때 사용할 사이트 기본 이미지입니다. 링크 공유 시 미리보기 카드에 표시됩니다.",
"twitter_default_card": "Twitter 카드 기본 타입",
"twitter_default_card_hint": "이미지가 큰 카드는 summary_large_image, 작은 카드는 summary 권장.",
"twitter_default_site": "Twitter 사이트 핸들",
diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json
index 8484d60f..9e86b7a6 100644
--- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json
+++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json
@@ -395,6 +395,107 @@
},
"text": "$t:admin.settings.seo.og_image_default_size_hint"
},
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "form-label"
+ },
+ "text": "$t:admin.settings.seo.og_image_default"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-tertiary mb-2"
+ },
+ "text": "$t:admin.settings.seo.og_image_default_desc"
+ },
+ {
+ "id": "og_image_default_uploader",
+ "type": "composite",
+ "name": "FileUploader",
+ "props": {
+ "collection": "og_image_default",
+ "disabled": "{{_computed.isReadOnly}}",
+ "maxFiles": 1,
+ "maxSize": "{{_global.settings?.upload?.max_file_size ?? _global.uploadSettings?.max_file_size ?? 10}}",
+ "accept": "{{_global.settings?.upload?.allowed_extensions_formatted ?? _global.uploadSettings?.allowed_extensions_formatted ?? '.jpg,.jpeg,.png,.gif,.svg,.webp'}}",
+ "imageCompression": {
+ "maxSizeMB": 1,
+ "maxWidthOrHeight": "{{_global.settings?.upload?.image_max_width ?? _global.uploadSettings?.image_max_width ?? 1200}}"
+ },
+ "initialFiles": "{{settings?.data?.seo?.og_image_default ?? []}}",
+ "autoUpload": false,
+ "uploadTriggerEvent": "upload:og_image_default",
+ "confirmBeforeRemove": true
+ },
+ "actions": [
+ {
+ "event": "onFilesChange",
+ "type": "change",
+ "if": "{{$args[0] && $args[0].length > 0}}",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "hasChanges": true
+ }
+ },
+ {
+ "event": "onUploadComplete",
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "hasChanges": true,
+ "form.seo.og_image_default": "{{[...(_local.form?.seo?.og_image_default ?? []), ...$args[0]]}}"
+ }
+ },
+ {
+ "event": "onUploadComplete",
+ "type": "custom",
+ "handler": "toast",
+ "params": {
+ "type": "success",
+ "message": "$t:attachment.upload_success"
+ }
+ },
+ {
+ "event": "onUploadError",
+ "type": "custom",
+ "handler": "toast",
+ "params": {
+ "type": "error",
+ "message": "{{$args[0]}}"
+ }
+ },
+ {
+ "event": "onRemove",
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "hasChanges": true,
+ "form.seo.og_image_default": "{{(_local.form?.seo?.og_image_default ?? []).filter(item => (item.hash || item.id) !== $args[0])}}"
+ }
+ },
+ {
+ "event": "onRemove",
+ "type": "custom",
+ "handler": "toast",
+ "params": {
+ "type": "info",
+ "message": "$t:attachment.deleted"
+ }
+ }
+ ]
+ }
+ ]
+ },
{
"type": "basic",
"name": "Div",
@@ -1197,7 +1298,7 @@
"props": {
"type": "button",
"disabled": "{{_computed.isReadOnly || ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}",
- "className": "w-full md:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
+ "className": "w-full md:w-auto px-4 py-2 bg-blue-600 dark:bg-blue-500 hover:bg-blue-700 dark:hover:bg-blue-600 disabled:bg-gray-400 dark:disabled:bg-gray-600 text-white dark:text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
},
"text": "{{['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '') ? $t('admin.settings.seo.sitemap_regenerating') : $t('admin.settings.seo.sitemap_manual_update')}}",
"actions": [
diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json
index c783e329..25df1b63 100644
--- a/templates/_bundled/sirsoft-admin_basic/package-lock.json
+++ b/templates/_bundled/sirsoft-admin_basic/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sirsoft-admin_basic",
- "version": "1.0.6",
+ "version": "1.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-admin_basic",
- "version": "1.0.6",
+ "version": "1.0.7",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/templates/_bundled/sirsoft-admin_basic/package.json b/templates/_bundled/sirsoft-admin_basic/package.json
index 90f5d65b..5cd8ef42 100644
--- a/templates/_bundled/sirsoft-admin_basic/package.json
+++ b/templates/_bundled/sirsoft-admin_basic/package.json
@@ -1,6 +1,6 @@
{
"name": "sirsoft-admin_basic",
- "version": "1.0.6",
+ "version": "1.0.7",
"description": "Gnuboard7 Basic Admin Template Components",
"type": "module",
"main": "dist/components.js",
diff --git a/templates/_bundled/sirsoft-admin_basic/template.json b/templates/_bundled/sirsoft-admin_basic/template.json
index 9c1f06ec..8cd97026 100644
--- a/templates/_bundled/sirsoft-admin_basic/template.json
+++ b/templates/_bundled/sirsoft-admin_basic/template.json
@@ -5,7 +5,7 @@
"ko": "Admin Basic",
"en": "Admin Basic"
},
- "version": "1.0.6",
+ "version": "1.0.7",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 관리자 템플릿",
@@ -22,7 +22,7 @@
"url": "https://sirsoft.com"
},
"release_date": "2026-05-15",
- "g7_version": ">=7.0.7",
+ "g7_version": ">=7.0.9",
"dependencies": {
"modules": {},
"plugins": {}
diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md
index 986c462b..47f13363 100644
--- a/templates/_bundled/sirsoft-basic/CHANGELOG.md
+++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md
@@ -4,6 +4,16 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.2] - 2026-08-22
+
+### Changed
+
+- sirsoft-page 최소 버전을 1.1.0 으로 상향했습니다 (페이지 공유 이미지 연동).
+
+### Fixed
+
+- 페이지 공유 미리보기의 이미지(og:image)와 설명(og:description)이 채워지도록 페이지 표시 화면의 공유 정보 연결을 정비했습니다. (#22 @abc101 님께서 제보해주셨습니다.)
+
## [1.1.1] - 2026-08-19
### Added
diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx
new file mode 100644
index 00000000..31b3e79c
--- /dev/null
+++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/search-product-item-list-thumbnail.test.tsx
@@ -0,0 +1,144 @@
+/**
+ * @file search-product-item-list-thumbnail.test.tsx
+ * @description 통합검색 상품 리스트형 아이템 썸네일 키 정정 회귀 (공개 #22 부수 — 별건 P3-A)
+ *
+ * 회귀 배경: `_item_list.json` 이 生産측(SearchProductsListener)이 방출하지 않는
+ * `product.thumbnail` 키를 바인딩해 리스트형 썸네일 src 가 항상 비었다 (카드형은 정상).
+ * 정정: `product.thumbnail_url` 바인딩 + if 가드 + 부재 시 placeholder.
+ *
+ * 검증 방식: 실제 partial JSON 을 iteration 래퍼에 넣어 DynamicRenderer 로 렌더링,
+ * thumbnail_url 존재/부재 두 케이스의 DOM 을 단언한다.
+ *
+ * @vitest-environment jsdom
+ */
+
+import React from 'react';
+import { describe, it, expect, afterEach } from 'vitest';
+import { createLayoutTest, screen } from '@/core/template-engine/__tests__/utils/layoutTestUtils';
+import { ComponentRegistry } from '@/core/template-engine/ComponentRegistry';
+
+import itemListPartial from '../../layouts/partials/search/products/_item_list.json';
+
+// ========== 테스트용 컴포넌트 ==========
+
+const TestDiv: React.FC<{ className?: string; children?: React.ReactNode }> = ({ className, children }) => (
+ {children}
+);
+
+const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = ({ className, children, text }) => (
+ {children || text}
+);
+
+const TestButton: React.FC<{ className?: string; children?: React.ReactNode }> = ({ className, children }) => (
+
+);
+
+const TestImg: React.FC<{ src?: string; alt?: string; className?: string }> = ({ src, alt, className }) => (
+
+);
+
+const TestIcon: React.FC<{ name?: string; className?: string }> = ({ name, className }) => (
+
+);
+
+const TestHtmlContent: React.FC<{ content?: string; className?: string }> = ({ content, className }) => (
+ {content}
+);
+
+const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}>;
+
+function setupTestRegistry(): void {
+ const registry = ComponentRegistry.getInstance();
+ (registry as any).registry = {
+ Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } },
+ Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } },
+ Button: { component: TestButton, metadata: { name: 'Button', type: 'basic' } },
+ Img: { component: TestImg, metadata: { name: 'Img', type: 'basic' } },
+ Icon: { component: TestIcon, metadata: { name: 'Icon', type: 'basic' } },
+ HtmlContent: { component: TestHtmlContent, metadata: { name: 'HtmlContent', type: 'composite' } },
+ // iteration 렌더는 Fragment 래퍼를 요구한다 — 미등록 시 반복 전체가 조용히 빈 출력
+ Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } },
+ };
+}
+
+/**
+ * 실제 partial 을 iteration 래퍼에 넣은 테스트 레이아웃을 만든다.
+ *
+ * @param products 검색 결과 상품 배열 (SearchProductsListener 방출 형태)
+ */
+const testLayout = {
+ version: '1.0.0',
+ layout_name: 'test/search-item-list',
+ components: [
+ {
+ type: 'basic',
+ name: 'Div',
+ iteration: {
+ source: 'search_products.data',
+ item_var: 'product',
+ index_var: 'product_index',
+ },
+ children: [itemListPartial],
+ },
+ ],
+};
+
+/**
+ * 검색 결과 상품 배열을 initialData 컨텍스트로 넘겨 테스트 유틸을 만든다.
+ *
+ * @param products 검색 결과 상품 배열 (SearchProductsListener 방출 형태)
+ */
+function createUtils(products: Array>) {
+ return createLayoutTest(testLayout as any, {
+ componentRegistry: ComponentRegistry.getInstance(),
+ initialData: { search_products: { data: products } },
+ } as any);
+}
+
+describe('통합검색 상품 리스트형 썸네일 (partials/search/products/_item_list.json)', () => {
+ let cleanup: (() => void) | null = null;
+
+ afterEach(() => {
+ cleanup?.();
+ cleanup = null;
+ });
+
+ it('thumbnail_url 이 있으면 그 값으로 Img 가 렌더된다 (死키 thumbnail 미의존)', async () => {
+ setupTestRegistry();
+ const utils = createUtils([
+ {
+ id: 1,
+ name: '검색 상품',
+ category_name: '카테고리',
+ thumbnail_url: '/api/plugins/sirsoft-ckeditor5/images/abc123',
+ selling_price_formatted: '10,000원',
+ },
+ ]);
+ cleanup = () => utils.cleanup();
+
+ await utils.render();
+
+ const img = screen.getByTestId('product-thumb');
+ expect(img.getAttribute('src')).toBe('/api/plugins/sirsoft-ckeditor5/images/abc123');
+ expect(screen.queryByTestId('icon-image')).toBeNull();
+ });
+
+ it('thumbnail_url 이 없으면 Img 대신 placeholder 아이콘이 렌더된다', async () => {
+ setupTestRegistry();
+ const utils = createUtils([
+ {
+ id: 2,
+ name: '이미지 없는 상품',
+ category_name: '카테고리',
+ thumbnail_url: null,
+ selling_price_formatted: '5,000원',
+ },
+ ]);
+ cleanup = () => utils.cleanup();
+
+ await utils.render();
+
+ expect(screen.queryByTestId('product-thumb')).toBeNull();
+ expect(screen.getByTestId('icon-image')).toBeTruthy();
+ });
+});
diff --git a/templates/_bundled/sirsoft-basic/layouts/page/show.json b/templates/_bundled/sirsoft-basic/layouts/page/show.json
index 0aaa25ce..7d76ab53 100644
--- a/templates/_bundled/sirsoft-basic/layouts/page/show.json
+++ b/templates/_bundled/sirsoft-basic/layouts/page/show.json
@@ -15,12 +15,14 @@
"og": {
"type": "website",
"title": "{{page.data.title ?? ''}}",
- "description": "{{page.data.excerpt ?? ''}}"
+ "description": "{{page.data.seo_meta?.description ?? ''}}",
+ "image": "{{page.data.content_thumbnail_url ?? ''}}"
},
"structured_data": {
"@type": "WebPage",
"name": "{{page.data.title ?? ''}}",
- "description": "{{page.data.excerpt ?? ''}}",
+ "description": "{{page.data.seo_meta?.description ?? ''}}",
+ "image": "{{page.data.content_thumbnail_url ?? ''}}",
"datePublished": "{{page.data.created_at ?? ''}}",
"dateModified": "{{page.data.updated_at ?? ''}}"
}
@@ -302,7 +304,7 @@
"if": "{{!att_item.is_image}}",
"props": {
"name": "file",
- "className": "text-gray-400 text-sm flex-shrink-0"
+ "className": "text-gray-400 dark:text-gray-500 text-sm flex-shrink-0"
}
},
{
diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json b/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json
index 8c6eb36c..fb9c0710 100644
--- a/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json
+++ b/templates/_bundled/sirsoft-basic/layouts/partials/search/products/_item_list.json
@@ -12,9 +12,25 @@
"props": { "className": "w-16 h-16 flex-shrink-0 rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700" },
"children": [
{
+ "comment": "생산측(SearchProductsListener)은 thumbnail_url 키를 방출한다 — thumbnail 은 死키였다 (공개 #22 부수 정정)",
"type": "basic",
"name": "Img",
- "props": { "src": "{{product.thumbnail}}", "alt": "{{product.name}}", "className": "w-full h-full object-cover" }
+ "if": "{{product.thumbnail_url}}",
+ "props": { "src": "{{product.thumbnail_url}}", "alt": "{{product.name}}", "className": "w-full h-full object-cover" }
+ },
+ {
+ "comment": "이미지가 없는 경우 플레이스홀더 (board 카드형 패턴 축소 미러)",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{!product.thumbnail_url}}",
+ "props": { "className": "w-full h-full flex items-center justify-center bg-gray-200 dark:bg-gray-700" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": { "name": "image", "className": "text-gray-400 dark:text-gray-500" }
+ }
+ ]
}
]
},
diff --git a/templates/_bundled/sirsoft-basic/package-lock.json b/templates/_bundled/sirsoft-basic/package-lock.json
index d1d323cf..3458b0ef 100644
--- a/templates/_bundled/sirsoft-basic/package-lock.json
+++ b/templates/_bundled/sirsoft-basic/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sirsoft-basic",
- "version": "1.1.1",
+ "version": "1.1.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-basic",
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/templates/_bundled/sirsoft-basic/package.json b/templates/_bundled/sirsoft-basic/package.json
index c713487d..22721d03 100644
--- a/templates/_bundled/sirsoft-basic/package.json
+++ b/templates/_bundled/sirsoft-basic/package.json
@@ -1,6 +1,6 @@
{
"name": "sirsoft-basic",
- "version": "1.1.1",
+ "version": "1.1.2",
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
"type": "module",
"main": "dist/components.js",
diff --git a/templates/_bundled/sirsoft-basic/template.json b/templates/_bundled/sirsoft-basic/template.json
index 6930220e..34d5c6dc 100644
--- a/templates/_bundled/sirsoft-basic/template.json
+++ b/templates/_bundled/sirsoft-basic/template.json
@@ -5,7 +5,7 @@
"ko": "Basic",
"en": "Basic"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 사용자 템플릿",
@@ -22,12 +22,12 @@
"url": "https://sirsoft.com"
},
"release_date": "2026-01-07",
- "g7_version": ">=7.0.6",
+ "g7_version": ">=7.0.9",
"dependencies": {
"modules": {
"sirsoft-board": ">=1.0.0",
"sirsoft-ecommerce": ">=1.1.0",
- "sirsoft-page": ">=1.0.0"
+ "sirsoft-page": ">=1.1.0"
},
"plugins": {
"sirsoft-daum_postcode": ">=1.0.0"
diff --git a/tests/Feature/Settings/OgDefaultImageSettingTest.php b/tests/Feature/Settings/OgDefaultImageSettingTest.php
new file mode 100644
index 00000000..c572d347
--- /dev/null
+++ b/tests/Feature/Settings/OgDefaultImageSettingTest.php
@@ -0,0 +1,126 @@
+ 'core',
+ 'source_identifier' => 'og_image_default',
+ 'hash' => bin2hex(random_bytes(6)),
+ 'original_filename' => 'og-default.png',
+ 'stored_filename' => 'og-default-'.uniqid().'.png',
+ 'disk' => 'public',
+ 'path' => 'settings/og-default.png',
+ 'mime_type' => 'image/png',
+ 'size' => 1024,
+ 'collection' => 'og_image_default',
+ 'order' => 0,
+ ]);
+ }
+
+ /**
+ * 기존 설치본에도 새 키가 기본값으로 노출되는지 확인합니다 (defaults 병합).
+ *
+ * @scenario image_chain=none, setting_state=absent
+ *
+ * @effects og_default_image_persists_to_seo_category
+ */
+ public function test_new_key_present_through_defaults_merge(): void
+ {
+ $seo = app(ConfigRepositoryInterface::class)->getCategory('seo');
+
+ $this->assertArrayHasKey('og_image_default', $seo);
+ $this->assertSame([], $seo['og_image_default']);
+ }
+
+ /**
+ * seo 탭 저장 시 첨부 ID 배열이 정제되어 seo 카테고리에 기록되는지 확인합니다.
+ *
+ * @scenario image_chain=site_default, setting_state=saved
+ *
+ * @effects og_default_image_persists_to_seo_category
+ */
+ public function test_saved_attachment_ids_persist_to_seo_category(): void
+ {
+ $attachment = $this->createOgImageAttachment();
+
+ $saved = app(SettingsService::class)->saveSettings([
+ '_tab' => 'seo',
+ 'seo' => [
+ // 화면 제출 형태(첨부 객체 배열)와 ID 배열 모두 수용해야 한다
+ 'og_image_default' => [['id' => $attachment->id, 'hash' => $attachment->hash]],
+ ],
+ ]);
+
+ $this->assertTrue($saved, 'seo 탭 저장이 실패했습니다.');
+
+ $seo = app(ConfigRepositoryInterface::class)->getCategory('seo');
+ $this->assertSame([$attachment->id], $seo['og_image_default']);
+
+ // 존재하지 않는 첨부 ID 는 정제 단계에서 걸러진다
+ app(SettingsService::class)->saveSettings([
+ '_tab' => 'seo',
+ 'seo' => ['og_image_default' => [999999]],
+ ]);
+
+ $this->assertSame([], app(ConfigRepositoryInterface::class)->getCategory('seo')['og_image_default']);
+ }
+
+ /**
+ * 저장된 첨부가 getOgDefaultImageUrl 로 첫 이미지 URL 로 해석되는지 확인합니다.
+ *
+ * @scenario image_chain=site_default, setting_state=saved
+ *
+ * @effects og_default_image_resolves_first_image_url
+ */
+ public function test_resolves_first_image_url(): void
+ {
+ $attachment = $this->createOgImageAttachment();
+
+ app(SettingsService::class)->saveSettings([
+ '_tab' => 'seo',
+ 'seo' => ['og_image_default' => [$attachment->id]],
+ ]);
+
+ $url = app(SettingsService::class)->getOgDefaultImageUrl();
+
+ $this->assertNotNull($url, '저장된 기본 OG 이미지가 URL 로 해석되어야 합니다.');
+ $this->assertStringContainsString($attachment->hash, $url);
+ }
+
+ /**
+ * 미설정이면 null 을 돌려주는지 확인합니다 (og:image 태그 미출력 경로).
+ *
+ * @scenario image_chain=none, setting_state=absent
+ *
+ * @effects og_default_image_resolves_first_image_url
+ */
+ public function test_resolves_null_when_not_configured(): void
+ {
+ $this->assertNull(app(SettingsService::class)->getOgDefaultImageUrl());
+ }
+}
diff --git a/tests/Unit/Seo/SeoMetaResolverTest.php b/tests/Unit/Seo/SeoMetaResolverTest.php
index 9957dd86..2626b439 100644
--- a/tests/Unit/Seo/SeoMetaResolverTest.php
+++ b/tests/Unit/Seo/SeoMetaResolverTest.php
@@ -4,7 +4,9 @@ namespace Tests\Unit\Seo;
use App\Seo\ExpressionEvaluator;
use App\Seo\SeoMetaResolver;
+use App\Services\SettingsService;
use Illuminate\Support\Facades\Config;
+use Illuminate\Support\Facades\URL;
use Tests\TestCase;
/**
@@ -1484,6 +1486,88 @@ class SeoMetaResolverTest extends TestCase
$this->assertStringContainsString('@context', $json);
}
+ // ── og:image 사이트 기본값 폴백 (공개 이슈 #22) ──────────────
+
+ /**
+ * getOgDefaultImageUrl 이 지정 값을 돌려주도록 SettingsService 를 스텁합니다.
+ *
+ * @param string|null $url 기본 OG 이미지 URL
+ */
+ private function stubOgDefaultImage(?string $url): void
+ {
+ $mock = \Mockery::mock(SettingsService::class);
+ $mock->shouldReceive('getOgDefaultImageUrl')->andReturn($url);
+ $this->app->instance(SettingsService::class, $mock);
+ }
+
+ /**
+ * 레이아웃 og.image 선언이 있으면 사이트 기본값을 쓰지 않아야 합니다 (선언 우선).
+ *
+ * @scenario image_chain=layout_declared, setting_state=saved
+ *
+ * @effects layout_declaration_wins_over_site_default
+ */
+ public function test_og_image_declaration_wins_over_site_default(): void
+ {
+ $this->stubOgDefaultImage('/storage/settings/site-default.png');
+
+ $result = $this->resolver->resolveOgData(
+ ['og' => ['image' => '/storage/posts/declared.jpg']],
+ [],
+ 'Fallback Title',
+ 'Fallback Description'
+ );
+
+ $this->assertStringContainsString('/storage/posts/declared.jpg', $result['image']);
+ $this->assertStringNotContainsString('site-default', $result['image']);
+ }
+
+ /**
+ * 선언이 빈 값이면 사이트 기본 OG 이미지로 폴백하고 secure_url 도 파생되어야 합니다.
+ *
+ * @scenario image_chain=site_default, setting_state=saved
+ *
+ * @effects empty_declaration_falls_back_to_site_default
+ */
+ public function test_og_image_falls_back_to_site_default_and_derives_secure_url(): void
+ {
+ // absoluteUrl 은 부팅 시점 URL 루트를 쓰므로 강제 지정으로 고정한다
+ URL::forceRootUrl('https://shop.example.com');
+ URL::forceScheme('https');
+ $this->stubOgDefaultImage('/storage/settings/site-default.png');
+
+ try {
+ $result = $this->resolver->resolveOgData(['og' => []], [], 'T', 'D');
+ } finally {
+ URL::forceRootUrl(null);
+ URL::forceScheme(null);
+ }
+
+ $this->assertSame('https://shop.example.com/storage/settings/site-default.png', $result['image']);
+ $this->assertSame(
+ $result['image'],
+ $result['image_secure_url'],
+ 'secure_url 은 폴백 적용 후의 image 를 기준으로 파생되어야 합니다.'
+ );
+ }
+
+ /**
+ * 선언도 기본값도 없으면 image 가 빈 값이어야 합니다 (태그 미출력).
+ *
+ * @scenario image_chain=none, setting_state=absent
+ *
+ * @effects no_image_emits_nothing
+ */
+ public function test_og_image_empty_when_no_declaration_and_no_default(): void
+ {
+ $this->stubOgDefaultImage(null);
+
+ $result = $this->resolver->resolveOgData(['og' => []], [], 'T', 'D');
+
+ $this->assertSame('', $result['image']);
+ $this->assertSame('', $result['image_secure_url']);
+ }
+
/**
* 회귀: 코어 설정 site_name 이 다국어 JSON 배열로 저장된 경우에도
* resolveOgData 가 'Array to string conversion' throw 없이 정상 동작.
diff --git a/tests/Unit/Support/HtmlImageExtractorTest.php b/tests/Unit/Support/HtmlImageExtractorTest.php
new file mode 100644
index 00000000..b1b1c6d4
--- /dev/null
+++ b/tests/Unit/Support/HtmlImageExtractorTest.php
@@ -0,0 +1,202 @@
+ 'https://shop.example.com']);
+ }
+
+ // ── candidates: src 수집 ──────────────────────────
+
+ public function test_candidates_collects_srcs_in_document_order(): void
+ {
+ $html = 'a

';
+
+ $this->assertSame(
+ ['/one.jpg', '/two.png', 'https://ext.com/three.gif'],
+ HtmlImageExtractor::candidates($html)
+ );
+ }
+
+ public function test_candidates_returns_empty_for_no_images(): void
+ {
+ $this->assertSame([], HtmlImageExtractor::candidates('텍스트만 있는 본문
'));
+ $this->assertSame([], HtmlImageExtractor::candidates(''));
+ $this->assertSame([], HtmlImageExtractor::candidates(' '));
+ }
+
+ public function test_candidates_skips_empty_src(): void
+ {
+ $this->assertSame(
+ ['/real.jpg'],
+ HtmlImageExtractor::candidates('![]()
![]()

')
+ );
+ }
+
+ public function test_candidates_survives_broken_html(): void
+ {
+ // 미닫힘 태그 + 중첩 오류 — DOM 파서 관용 파싱으로 예외 없이 처리돼야 한다
+ $this->assertIsArray(HtmlImageExtractor::candidates('
텍스트
'));
+
+ // 미닫힘이어도 정상 형태의 img 는 수집된다
+ $this->assertSame(
+ ['/ok.jpg'],
+ HtmlImageExtractor::candidates('
미닫힘 문단
')
+ );
+ }
+
+ public function test_candidates_safe_on_plain_text(): void
+ {
+ $this->assertSame([], HtmlImageExtractor::candidates('그냥 평문입니다.
assertSame(
+ ['/single.jpg', '/double.jpg'],
+ HtmlImageExtractor::candidates("
")
+ );
+ }
+
+ // ── firstInternal: 내부 판정 ──────────────────────────
+
+ /**
+ * @effects content_internal_image_fills_list_thumbnail
+ */
+ public function test_relative_path_passes(): void
+ {
+ $this->assertSame(
+ '/storage/uploads/a.jpg',
+ HtmlImageExtractor::firstInternal('
')
+ );
+ }
+
+ public function test_absolute_same_host_converted_to_relative(): void
+ {
+ $this->assertSame(
+ '/storage/uploads/a.jpg?v=1',
+ HtmlImageExtractor::firstInternal('
')
+ );
+ }
+
+ public function test_absolute_same_host_case_insensitive(): void
+ {
+ $this->assertSame(
+ '/img/a.png',
+ HtmlImageExtractor::firstInternal('
')
+ );
+ }
+
+ /**
+ * @effects external_only_content_yields_null
+ */
+ public function test_external_host_excluded(): void
+ {
+ $this->assertNull(
+ HtmlImageExtractor::firstInternal('
')
+ );
+ }
+
+ public function test_protocol_relative_excluded(): void
+ {
+ $this->assertNull(
+ HtmlImageExtractor::firstInternal('
')
+ );
+ }
+
+ /**
+ * 정규화 공격 문자열 — 문자열상 path 지만 브라우저는 외부 origin 으로 읽는 형태.
+ * 정규화 SSoT 를 거치지 않으면 내부로 오판된다.
+ */
+ public function test_normalization_attack_strings_judged_external(): void
+ {
+ $attacks = [
+ '/\\/evil.com/x.jpg',
+ '\\\\evil.com\\x.jpg',
+ "/\t/evil.com/x.jpg",
+ '///evil.com/x.jpg',
+ ];
+
+ foreach ($attacks as $attack) {
+ $this->assertNull(
+ HtmlImageExtractor::firstInternal('
'),
+ "외부 판정 실패: {$attack}"
+ );
+ }
+ }
+
+ public function test_non_http_schemes_excluded(): void
+ {
+ $this->assertNull(HtmlImageExtractor::firstInternal('
'));
+ $this->assertNull(HtmlImageExtractor::firstInternal('
'));
+ $this->assertNull(HtmlImageExtractor::firstInternal('
'));
+ $this->assertNull(HtmlImageExtractor::firstInternal('
'));
+ }
+
+ public function test_first_external_then_internal_picks_internal(): void
+ {
+ $html = '
';
+
+ $this->assertSame('/second.jpg', HtmlImageExtractor::firstInternal($html));
+ }
+
+ public function test_no_image_returns_null(): void
+ {
+ $this->assertNull(HtmlImageExtractor::firstInternal('이미지 없음
'));
+ $this->assertNull(HtmlImageExtractor::firstInternal(''));
+ }
+
+ public function test_extra_allowed_prefix_passes_with_original_form(): void
+ {
+ $html = '
';
+
+ $this->assertNull(HtmlImageExtractor::firstInternal($html));
+ $this->assertSame(
+ 'https://cdn.example.net/bucket/a.jpg',
+ HtmlImageExtractor::firstInternal($html, ['https://cdn.example.net/'])
+ );
+ }
+
+ public function test_extra_prefix_does_not_rescue_unlisted_host(): void
+ {
+ $this->assertNull(
+ HtmlImageExtractor::firstInternal(
+ '
',
+ ['https://cdn.example.net/']
+ )
+ );
+ }
+
+ public function test_url_over_1000_chars_excluded(): void
+ {
+ $longUrl = '/storage/'.str_repeat('a', 1000).'.jpg';
+ $html = '
';
+
+ $this->assertSame('/short.jpg', HtmlImageExtractor::firstInternal($html));
+ }
+
+ public function test_absolute_same_host_without_path_returns_root(): void
+ {
+ $this->assertSame(
+ '/',
+ HtmlImageExtractor::firstInternal('
')
+ );
+ }
+}
diff --git a/tests/scenarios/seo-og-default-image.yaml b/tests/scenarios/seo-og-default-image.yaml
new file mode 100644
index 00000000..d0e4670f
--- /dev/null
+++ b/tests/scenarios/seo-og-default-image.yaml
@@ -0,0 +1,28 @@
+feature: 사이트 기본 OG 이미지 폴백
+
+description: |
+ 공개 이슈 #22 동종(증폭 레이어) — SeoMetaResolver 의 og:image 는 title/description 과
+ 달리 폴백 체인이 없어, 레이아웃 선언이 비면 그대로 비었다. 환경설정 > SEO 에 사이트
+ 기본 공유 이미지(seo.og_image_default, 첨부 ID 배열)를 신설하고, resolveOgData 가
+ 선언이 빈 값일 때 이 기본값으로 폴백한다. 체인: 레이아웃 선언(도메인 본문 캐시 포함)
+ → 사이트 기본 이미지 → 빈 값(태그 미출력). secure_url 은 폴백 적용 후 image 기준 파생.
+
+axes:
+ image_chain: [layout_declared, site_default, none]
+ setting_state: [saved, absent]
+
+exclusions:
+ - { image_chain: layout_declared, setting_state: absent, reason: "선언이 있으면 기본값 설정 유무는 결과에 관여하지 않는다 — saved 대표 조합이 선언 우선을 검증" }
+ - { image_chain: site_default, setting_state: absent, reason: "기본값 미설정이면 site_default 체인 자체가 성립하지 않는다 (none 조합이 그 상태)" }
+ - { image_chain: none, setting_state: saved, reason: "none 은 선언·기본값 모두 부재의 정의 — saved 와 모순 조합" }
+
+effects:
+ - layout_declaration_wins_over_site_default
+ - empty_declaration_falls_back_to_site_default
+ - no_image_emits_nothing
+ - og_default_image_persists_to_seo_category
+ - og_default_image_resolves_first_image_url
+
+test_files:
+ - tests/Unit/Seo/SeoMetaResolverTest.php
+ - tests/Feature/Settings/OgDefaultImageSettingTest.php
From 6a8a537f100832d3d1a7cfb852070149efea3e2f Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Tue, 18 Aug 2026 23:44:23 +0900
Subject: [PATCH 2/7] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=9E=84?=
=?UTF-8?q?=EC=8B=9C=20=EC=82=AD=EC=A0=9C=EB=90=9C=20=EB=B9=84=EC=A6=88?=
=?UTF-8?q?=EB=BF=8C=EB=A6=AC=EC=98=A4=20=ED=94=8C=EB=9F=AC=EA=B7=B8?=
=?UTF-8?q?=EC=9D=B8=20=EB=B3=B5=EA=B5=AC=20=EB=B0=8F=20=ED=98=84=ED=96=89?=
=?UTF-8?q?=20=EA=B7=9C=EC=A0=95=20=EC=A0=95=ED=95=A9=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
32afe9530 에서 게시판 반응 기능과 함께 임시 제거됐던 비즈뿌리오 메시징
플러그인 본체(137 파일)와 일본어 번들 언어팩(9 파일)을 삭제 직전 상태
(32afe9530~1)로 복구한다. 템플릿 신청·승인 개편의 기반 작업.
복구 범위
- plugins/_bundled/sirsoft-message_bizppurio 전체 + 번들 ja 언어팩
- 공유 파일의 비즈뿌리오 참조 복원: build-language-pack-ja.cjs 팩 정의,
api-doc-unfilled-baseline.json(32건), docs/backend/api/README.md 표 행,
audit coverage 노트·vite-sourcemap-env-gate 룰 주석, via 테스트 주석,
·AGENTS.md 확장 API 표(자동 재생성), 라우팅 패리티 스냅샷(+1)
삭제 이후 강화된 규정 2건 정합화
- TokenCheckController: 예외 원문을 메시지 키 자리에 전달하던 422 응답을
키(token_check.failed) + errors.bizppurio_message 페이로드로 분리
(GenericCatchStatusCodeContractTest 계약). 관리자 토스트는 errors 페이로드로
상세 사유를 계속 표시하도록 레이아웃 동기 수정, lang ko/en/ja 키 추가
- AlimtalkTemplateController::index: base Request 주입 금지 룰에 따라
AlimtalkTemplateListRequest FormRequest 신설 (형태 검증만 — kapi 위임 유지)
미복원(의도)
- 게시판·이커머스 CHANGELOG 의 알림톡 연결 문구 2줄은 연결 방식이 로
재설계되므로 되살리지 않고, 완료 시점에 새 동작 기준으로 차기 버전에 기재
검증: TokenCheck 7 + AlimtalkController 10 + GenericCatch 계약 3 (PHPUnit),
플러그인 레이아웃 Vitest 137건, BindingShape 라우팅 패리티 8건 green.
audit 는 복구 전부터 baseline 처리된 API 문서 미채움 32건만 잔존.
---
AGENTS.md | 3 +-
docs/backend/api/README.md | 1 +
.../CHANGELOG.md | 11 +
.../backend/ja/activity_log.php | 6 +
.../backend/ja/messages.php | 86 +
.../backend/ja/result_codes.php | 55 +
.../frontend/ja.json | 227 ++
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../seed/notifications.json | 18 +
.../seed/permissions.json | 18 +
.../sirsoft-message_bizppurio/CHANGELOG.md | 14 +
.../sirsoft-message_bizppurio/LICENSE | 47 +
.../sirsoft-message_bizppurio/README.md | 243 +++
.../sirsoft-message_bizppurio/components.json | 10 +
.../sirsoft-message_bizppurio/composer.json | 22 +
.../config/settings/defaults.json | 23 +
...0001_create_bizppurio_dispatches_table.php | 73 +
..._bizppurio_notification_bindings_table.php | 51 +
.../dist/js/plugin.iife.js | 1 +
.../docs/api/README.md | 20 +
.../docs/api/alimtalk-templates.md | 229 ++
.../docs/api/dispatch-results.md | 329 +++
.../docs/api/notification-bindings.md | 196 ++
.../docs/api/report.md | 80 +
.../docs/api/templates.md | 75 +
.../docs/api/token.md | 74 +
.../docs/api/webhook.md | 86 +
.../lang/en/activity_log.php | 10 +
.../lang/en/messages.php | 113 +
.../lang/en/result_codes.php | 76 +
.../lang/ko/activity_log.php | 9 +
.../lang/ko/messages.php | 113 +
.../lang/ko/result_codes.php | 79 +
.../sirsoft-message_bizppurio/package.json | 20 +
.../sirsoft-message_bizppurio/plugin.json | 32 +
.../sirsoft-message_bizppurio/plugin.php | 481 +++++
.../extensions/notification_log_result.json | 210 ++
.../extensions/notification_row_footer.json | 139 ++
.../extensions/notification_tab_board.json | 483 +++++
.../extensions/notification_tab_core.json | 483 +++++
.../notification_tab_ecommerce.json | 483 +++++
.../layouts/alimtalk_templates.test.tsx | 309 +++
.../resources/js/__tests__/layouts/helpers.ts | 120 ++
.../layouts/notification_log_result.test.tsx | 339 +++
.../layouts/notification_tab_binding.test.tsx | 238 +++
.../notification_tab_board_ecommerce.test.tsx | 167 ++
.../layouts/plugin_settings.test.tsx | 436 ++++
.../resources/js/__tests__/setup.ts | 25 +
.../resources/js/handlers/index.ts | 11 +
.../resources/js/index.ts | 95 +
.../resources/js/types.ts | 28 +
.../resources/lang/en.json | 227 ++
.../resources/lang/ko.json | 227 ++
.../layouts/admin/plugin_settings.json | 1863 +++++++++++++++++
.../src/Concerns/GuardsKakaoRequests.php | 42 +
.../src/Concerns/PreventsReplayWebhook.php | 43 +
.../Admin/AlimtalkTemplateController.php | 118 ++
.../Admin/DispatchResultController.php | 61 +
.../Admin/NotificationBindingController.php | 99 +
.../Admin/TokenCheckController.php | 66 +
.../BizppurioWebhookController.php | 49 +
.../src/Enums/DispatchChannel.php | 52 +
.../src/Enums/DispatchSource.php | 42 +
.../src/Enums/DispatchStatus.php | 54 +
.../src/Enums/ResultCategory.php | 69 +
.../src/Exceptions/BizppurioApiException.php | 60 +
.../NotificationSendSkippedException.php | 18 +
.../BizppurioWebhookIpWhitelist.php | 46 +
.../Requests/AlimtalkTemplateListRequest.php | 58 +
.../Http/Requests/BizppurioWebhookRequest.php | 49 +
.../Requests/DispatchResultLookupRequest.php | 52 +
.../StoreNotificationBindingRequest.php | 41 +
.../src/Jobs/SendMessageJob.php | 176 ++
.../BalanceLowNotificationDataListener.php | 110 +
.../Listeners/GuestPhoneExtractListener.php | 97 +
.../InvalidateTokenOnSettingsSaveListener.php | 83 +
.../Listeners/LinkNotificationLogListener.php | 90 +
.../RegisterNotificationChannelsListener.php | 373 ++++
.../SeedChannelTemplatesListener.php | 260 +++
.../ValidateBizppurioSettingsListener.php | 138 ++
.../src/Models/BizppurioDispatch.php | 150 ++
.../Models/BizppurioNotificationBinding.php | 87 +
.../MessageBizppurioServiceProvider.php | 130 ++
.../BizppurioDispatchRepository.php | 161 ++
...BizppurioNotificationBindingRepository.php | 79 +
.../BizppurioDispatchRepositoryInterface.php | 88 +
...NotificationBindingRepositoryInterface.php | 53 +
.../src/Services/AlimtalkChannelDriver.php | 301 +++
.../src/Services/AlimtalkPayloadMapper.php | 297 +++
.../src/Services/AlimtalkTemplateService.php | 273 +++
.../src/Services/BizppurioApiClient.php | 153 ++
.../src/Services/BizppurioKakaoApiClient.php | 210 ++
.../src/Services/BizppurioTokenService.php | 209 ++
.../src/Services/DispatchLinkContext.php | 55 +
.../src/Services/DispatchResultService.php | 103 +
.../Services/KakaoTemplateContentResolver.php | 193 ++
.../src/Services/MessagePayloadBuilder.php | 174 ++
.../Services/NotificationBindingService.php | 239 +++
.../src/Services/ResultCodeResolver.php | 122 ++
.../src/Services/SmsChannelDriver.php | 227 ++
.../src/Services/SmsTypeResolver.php | 90 +
.../src/Services/WebhookReportService.php | 158 ++
.../src/routes/api.php | 150 ++
.../AlimtalkTemplateControllerTest.php | 261 +++
.../ChannelTemplateCleanupOnUninstallTest.php | 144 ++
.../ChannelTemplateSeedOnActivateTest.php | 55 +
.../Feature/Installation/InstallationTest.php | 178 ++
.../ChannelRegistrationHookTest.php | 164 ++
.../DispatchResultEndpointTest.php | 228 ++
.../NotificationBindingEndpointTest.php | 251 +++
.../Notification/NotificationLogLinkTest.php | 159 ++
...izppurioLiveModeSettingsValidationTest.php | 228 ++
.../Settings/ReportUrlEndpointTest.php | 84 +
.../Settings/TokenCheckEndpointTest.php | 180 ++
.../Feature/Webhook/BizppurioWebhookTest.php | 319 +++
.../tests/PluginTestCase.php | 227 ++
.../tests/Unit/Enums/EnumTest.php | 64 +
.../tests/Unit/Jobs/SendMessageJobTest.php | 187 ++
...BalanceLowNotificationDataListenerTest.php | 127 ++
.../GuestPhoneExtractListenerTest.php | 95 +
...alidateTokenOnSettingsSaveListenerTest.php | 110 +
...gisterNotificationChannelsListenerTest.php | 227 ++
.../SeedChannelTemplatesListenerTest.php | 149 ++
.../Unit/Models/BizppurioDispatchTest.php | 90 +
.../BizppurioDispatchRepositoryTest.php | 135 ++
...purioNotificationBindingRepositoryTest.php | 67 +
.../Services/AlimtalkChannelDriverTest.php | 535 +++++
.../Services/AlimtalkPayloadMapperTest.php | 275 +++
.../Services/AlimtalkTemplateServiceTest.php | 173 ++
.../Unit/Services/BizppurioApiClientTest.php | 177 ++
.../Services/BizppurioKakaoApiClientTest.php | 151 ++
.../Services/BizppurioTokenServiceTest.php | 253 +++
.../Services/DispatchResultServiceTest.php | 145 ++
.../KakaoTemplateContentResolverTest.php | 174 ++
.../Services/MessagePayloadBuilderTest.php | 172 ++
.../NotificationBindingServiceTest.php | 316 +++
.../Unit/Services/ResultCodeResolverTest.php | 129 ++
.../Unit/Services/SmsChannelDriverTest.php | 364 ++++
.../Unit/Services/SmsTypeResolverTest.php | 63 +
.../scenarios/alimtalk-dispatch-binding.yaml | 148 ++
.../tests/scenarios/alimtalk-templates.yaml | 93 +
.../scenarios/dispatch-history-webhook.yaml | 74 +
.../scenarios/dispatch-result-injection.yaml | 91 +
.../tests/scenarios/plugin-settings.yaml | 112 +
.../tests/scenarios/sms-channel-dispatch.yaml | 77 +
.../sirsoft-message_bizppurio/tsconfig.json | 17 +
.../sirsoft-message_bizppurio/vite.config.ts | 44 +
.../vitest.config.ts | 17 +
.../BindingShape.routingParity.test.ts.snap | 1 +
.../GenericNotificationViaTest.php | 2 +-
151 files changed, 22226 insertions(+), 2 deletions(-)
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/LICENSE
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/README.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/components.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/composer.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/package.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/plugin.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/plugin.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Middleware/BizppurioWebhookIpWhitelist.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts
diff --git a/AGENTS.md b/AGENTS.md
index ad5912e2..3b25388d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -155,7 +155,7 @@
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
-### 확장 API 레퍼런스 (13개 확장, 자동 스캔)
+### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
@@ -168,6 +168,7 @@
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `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) | 7 / 13 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
diff --git a/docs/backend/api/README.md b/docs/backend/api/README.md
index 72652dfb..d41c588b 100644
--- a/docs/backend/api/README.md
+++ b/docs/backend/api/README.md
@@ -259,6 +259,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) | 6 / 12 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md
new file mode 100644
index 00000000..57ad5f67
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md
@@ -0,0 +1,11 @@
+# Changelog
+
+이 언어팩의 모든 주요 변경사항을 기록합니다.
+형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
+[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+
+## [1.0.0] - 2026-07-28
+
+### Added
+
+- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다.
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php
new file mode 100644
index 00000000..61dc2267
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/activity_log.php
@@ -0,0 +1,6 @@
+ [],
+ 'description' => [],
+];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php
new file mode 100644
index 00000000..417251d4
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php
@@ -0,0 +1,86 @@
+ [
+ 'sms' => 'SMS',
+ 'lms' => 'LMS',
+ 'alimtalk' => 'アラートトーク',
+ ],
+ 'status' => [
+ 'pending' => '待機中',
+ 'sent' => '送信中',
+ 'success' => '成功',
+ 'failed' => '失敗',
+ ],
+ 'source' => [
+ 'auto' => '自動',
+ 'manual' => '手動',
+ 'bulk' => '一括',
+ ],
+ 'result_category' => [
+ 'success' => '成功',
+ 'retry' => '再試行',
+ 'permanent_failure' => '永久失敗',
+ 'balance_low' => '残高不足',
+ ],
+ 'channels' => [
+ 'source_label' => 'ビズプリオ',
+ 'sms' => [
+ 'name' => 'SMS/LMSテキスト',
+ 'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。',
+ ],
+ 'alimtalk' => [
+ 'name' => 'カカオアラートトーク',
+ 'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。',
+ ],
+ ],
+ 'readiness' => [
+ 'sms_credentials_missing' => 'ビズプリオのアイディとパスワードを設定してください。',
+ 'sms_sender_number_missing' => '発信番号を設定してください。',
+ 'alimtalk_api_key_missing' => 'カカオ管理API キーを設定してください。',
+ 'alimtalk_sender_key_missing' => 'アラートトーク発信プロフィールキーを設定してください。',
+ ],
+ 'settings' => [
+ 'bizppurio_id_attribute' => 'ビズプリオアイディ',
+ 'password_attribute' => 'パスワード',
+ 'sender_number_attribute' => '発信番号',
+ ],
+ 'webhook' => [
+ 'received' => 'レポートを受け取りました。',
+ ],
+ 'error' => [
+ 'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。',
+ 'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。',
+ 'send_failed' => 'メッセージ送信リクエストに失敗しました。',
+ 'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)',
+ 'invalid_response' => 'ビズプリオレスポンスを解析できません。',
+ 'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
+ 'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
+ 'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
+ 'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)',
+ 'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
+ 'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
+ ],
+ 'send_skipped' => [
+ 'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)',
+ 'alimtalk_kakao_content_unavailable' => 'カカオ承認テンプレート内容を照会できないため送信をスキップしました。(通知タイプ: :type)',
+ 'sms_template_missing' => 'SMSテンプレートがないため送信をスキップしました。(通知タイプ: :type)',
+ 'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
+ 'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
+ ],
+ 'binding' => [
+ 'saved' => 'アラートトーク連携を保存しました。',
+ 'removed' => 'アラートトーク連携を解除しました。',
+ ],
+ 'cache' => [
+ 'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。',
+ ],
+ 'channel_group' => [
+ 'text' => '文字',
+ 'alimtalk' => '通知トーク',
+ ],
+ 'token_check' => [
+ 'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
+ 'failed' => '認証の確認に失敗しました。詳細な理由をご確認ください。',
+ ],
+];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php
new file mode 100644
index 00000000..8309d4c5
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/result_codes.php
@@ -0,0 +1,55 @@
+ '成功',
+ '2000' => 'メッセージが無効です',
+ '3001' => '認証情報が無効です(Basic)',
+ '3002' => 'トークンが無効です(期限切れ·廃止)',
+ '3003' => 'IPが無効です',
+ '3004' => 'アカウントが無効です',
+ '3005' => '認証情報が無効です(Bearer)',
+ '3006' => 'アカウントが存在しません',
+ '3007' => 'アカウントパスワードが無効です',
+ '3009' => 'アカウントが停止状態です',
+ '3010' => 'アクセス許可IPが一致しません',
+ '3011' => '不明なエラー(bizppurio)',
+ '3013' => '完了処理されていないメッセージ',
+ '4100' => '成功',
+ '4400' => '電波の弱い地域',
+ '4401' => '電源オフ',
+ '4402' => 'ストレージ超過',
+ '4410' => '不正な番号',
+ '4414' => '結番·停止',
+ '4420' => 'その他端末エラー',
+ '4430' => 'スパム',
+ '4431' => '送信制限受信拒否(スパム)',
+ '4443' => 'スパムブロック',
+ '5002' => 'リクエストが多すぎます',
+ '5003' => '一時的な送信エラー',
+ '5004' => '一時的な送信エラー',
+ '5005' => '一時的な送信エラー',
+ '6600' => '成功',
+ '6603' => '電波の弱い地域',
+ '6604' => '電源オフ',
+ '6606' => '不正な番号',
+ '6621' => 'メッセージ長超過',
+ '6641' => 'スパムブロック',
+ '7000' => '成功',
+ '7103' => '発信プロフィールキーが無効です',
+ '7106' => '削除された発信キー',
+ '7107' => 'ブロックされた発信キー',
+ '7204' => 'メッセージ内容がテンプレートと不一致です',
+ '7206' => 'シリアルナンバー形式が不一致です',
+ '7306' => 'カカオシステムエラー',
+ '7307' => '処理遅延',
+ '7308' => '電話番号エラー',
+ '7320' => '受信ブロック',
+ '7325' => '変数長超過',
+ '7421' => 'タイムアウト',
+ '7436' => 'ウォレット残高不足(アラートトーク)',
+ '7437' => 'メッセージリクエスト失敗',
+ '7523' => '080受信拒否(スパム)',
+ '9000' => '一時的なシステムエラー',
+ '9070' => '残高不足(SMS)',
+ '9071' => '後払い限度額超過',
+];
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json
new file mode 100644
index 00000000..25e709b3
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json
@@ -0,0 +1,227 @@
+{
+ "name": "ビズプリオ メッセージ発送",
+ "description": "ビズプリオ 連動 SMS/LMS・カカオ アラート톡 発送プラグインです。",
+ "settings": {
+ "title": "ビズプリオ メッセージ発送 設定",
+ "description": "ビズプリオ 連動情報と発送 設定を管理します。",
+ "save": "保存",
+ "saving": "保存 中...",
+ "save_success": "設定が保存されました。",
+ "save_failed": "設定 保存に失敗しました。",
+ "test_mode": {
+ "label": "検査モード",
+ "hint": "検査モードでは ビズプリオ 検査ドメインで発送されます。オフにすると運用ドメインで実際に発送されます。",
+ "account_notice": "検査と運用は別の ビズプリオ アカウントで運営することをお勧めします。"
+ },
+ "live_mode_warning_title": "運用環境で発送されます",
+ "live_mode_warning_body": "検査モードがオフになっているため、実際の顧客にSMS・アラート톡が発送され、発送費用が請求されます。検査段階では検査モードをオンにしてください。",
+ "sections": {
+ "api": {
+ "title": "API 連動",
+ "description": "発送システムとカカオ管理システムに共通に使用する連動情報です。"
+ },
+ "sending": {
+ "title": "発送 設定",
+ "description": "文字・アラート톡発送に使用する発信情報です。"
+ }
+ },
+ "fields": {
+ "bizppurio_id": {
+ "label": "ビズプリオ ID",
+ "hint": "発送・カカオ管理に共通に使用する ビズプリオ ID です。"
+ },
+ "password": {
+ "label": "ビズプリオモジュールパスワード",
+ "hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
+ },
+ "api_key": {
+ "label": "API キー",
+ "hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
+ },
+ "sender_number": {
+ "label": "発信番号",
+ "hint": "文字・アラート톡発送に使用する発信電話番号です。"
+ },
+ "sender_key": {
+ "label": "アラート톡 発信プロファイル キー",
+ "hint": "アラート톡 発送・テンプレート確認に使用する発信プロファイル キー(40文字)です。"
+ },
+ "template_cache_minutes": {
+ "label": "アラート톡 内容 キャッシュ 時間(分)",
+ "hint": "カカオ テンプレート内容をこの時間だけ再利用します。0 = 毎回最新(発送が多い場合は非推奨)。",
+ "clear_cache": "キャッシュ 初期化",
+ "clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。",
+ "clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。",
+ "clear_cache_failed": "キャッシュ 初期化に失敗しました。"
+ },
+ "connection_check": {
+ "label": "接続確認",
+ "hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
+ "button": "接続確認",
+ "checking": "確認中...",
+ "success": "認証が正常に確認されました。IDとパスワードが正しいです。",
+ "failed": "認証確認に失敗しました。",
+ "unsaved_changes": "変更内容を先に保存してください。"
+ }
+ },
+ "report": {
+ "section_title": "レポート受信 設定",
+ "hint": "ビズプリオ は文字・アラート톡発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを ビズプリオ ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
+ "note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。",
+ "copy": "コピー",
+ "copied": "レポート受信アドレスがクリップボードにコピーされました。"
+ },
+ "cache": {
+ "section_title": "アラート톡 発送内容 キャッシュ",
+ "section_description": "アラート톡は発送する際、カカオに登録されたテンプレート内容(本文・ボタン)をそのまま送信する必要があります。発送するたびにカカオに内容をリクエストしないよう、一度取得した内容を一定時間再利用(キャッシュ)します。これにより、発送が多くてもカカオ確認の制限に引っかからず、素早く発送されます。"
+ },
+ "tabs": {
+ "connection": "環境設定",
+ "templates": "アラート톡 テンプレート"
+ },
+ "preparation": {
+ "intro": "文字・カカオ アラート톡を発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
+ "sms_label": "文字(SMS/LMS)",
+ "sms_sender": "発信番号を登録してください。",
+ "kakao_label": "カカオ アラート톡",
+ "kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。",
+ "kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。",
+ "kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
+ "console_link": "ビズプリオ コンソールを開く"
+ }
+ },
+ "binding": {
+ "section_title": "カカオ アラート톡 連動",
+ "list_guide": "各 通知に発送するカカオ アラート톡 テンプレートを接続してください。[接続]で承認されたテンプレートを指定すると、該当イベント発生時にアラート톡が自動発送されます。",
+ "section_hint": "承認されたアラート톡 テンプレートのみ接続できます。保存するとこの 通知に即座に反映されます。",
+ "modal_title": "アラート톡 接続 · {name}",
+ "unbound": "未接続",
+ "unavailable": "使用不可 — 再接続が必要",
+ "btn_connect": "接続",
+ "btn_change": "接続を変更",
+ "fallback_on": "SMS 代替 ON",
+ "fallback_off": "SMS 代替 OFF",
+ "connected_template": "接続テンプレート",
+ "none": "接続しない",
+ "no_approved_templates": "発送可能な(承認された)アラート톡 テンプレートがありません。まずテンプレートを登録・検査してください。",
+ "templates_load_failed": "テンプレート リストを読み込みできませんでした。プラグイン 設定(認証情報)を確認してください。",
+ "fallback_sms": "失敗時 SMS で代替発送",
+ "fallback_hint": "アラート톡 発送が失敗すると、この 通知の本文内容が文字(SMS)で代わりに発送されます。",
+ "variables_hint": "提供変数(発送時に自動置換)",
+ "saved": "アラート톡 連動を保存しました。",
+ "save_error": "アラート톡 連動 保存に失敗しました。"
+ },
+ "banner": {
+ "not_ready": "発送に必要な 設定が完了していません。",
+ "setup_action": "設定する",
+ "test_mode": "検査モードです — 実際の発送は行われません。"
+ },
+ "dispatch_result": {
+ "column_header": "文字・アラート톡 結果",
+ "inspection_label": "検査",
+ "low_balance": "残高不足",
+ "fallback": "SMS 代替発送 {status}",
+ "detail_title": "文字・アラート톡 発送 結果",
+ "channel_label": "発送チャネル: {channel}",
+ "sent_content_label": "実際の発送内容",
+ "sent_content_hint": "アラート톡 はカカオ承認テンプレートの実際の内容で発送され、上記の\"本文\"と異なる場合があります。"
+ },
+ "editor": {
+ "data_source": {
+ "dispatch_results": "ビズプリオ送信結果"
+ }
+ },
+ "templates": {
+ "title": "アラームトークテンプレート",
+ "description": "カカオアラームトークテンプレートを登録·審査·管理します。承認されたテンプレートのみアラーム連動に使用できます。",
+ "readiness": {
+ "title": "アラームトークテンプレートを使用するには、以下の設定が必要です",
+ "go_settings": "環境設定に移動",
+ "missing_label": "未設定項目",
+ "api_key_missing": "カカオ管理API キー",
+ "sender_key_missing": "アラームトーク送信プロフィールキー",
+ "note": "上記項目を環境設定タブで入力すると、テンプレート照会·登録が可能になります。実際の送信は審査モードをオフにして運用に切り替えた後、カカオ承認を受けたテンプレートのみ使用できます。"
+ },
+ "list_error": {
+ "title": "テンプレート一覧を読み込めませんでした"
+ },
+ "list_notice": {
+ "console_desc": "テンプレートの登録·編集·審査はビズプリオコンソールで進めてください。",
+ "console_link": "ビズプリオコンソールを開く"
+ },
+ "list": {
+ "refresh": "更新",
+ "search": "検索",
+ "search_placeholder": "テンプレート名検索(2~50文字)",
+ "filter_all": "すべてのステータス",
+ "empty": "登録されたアラームトークテンプレートがありません。",
+ "empty_hint": "ビズプリオコンソールで登録したテンプレートがここに表示されます。",
+ "load_failed": "テンプレート一覧を読み込めませんでした。送信プロフィールキーとAPI キーを確認してください。",
+ "columns": {
+ "no": "番号",
+ "name": "テンプレート名",
+ "code": "コード",
+ "status": "ステータス",
+ "requested_at": "登録申請日",
+ "processed_at": "処理日",
+ "actions": "内容"
+ }
+ },
+ "status": {
+ "sendable": "送信可能",
+ "inspecting": "審査中",
+ "rejected": "却下",
+ "uninspected": "未審査",
+ "stopped": "停止",
+ "blocked": "ブロック",
+ "dormant": "休止",
+ "unknown": "不明"
+ },
+ "status_sub": {
+ "rdy": "(使用前)"
+ },
+ "status_guide": {
+ "title": "ステータスバッジ",
+ "sendable_label": "送信可能",
+ "sendable": "承認完了。このステータスのみアラームに接続·送信できます。",
+ "inspecting_label": "審査中",
+ "inspecting": "カカオ審査進行中(営業日2~3日)。",
+ "pending_label": "未審査·却下",
+ "pending": "まだ送信できません。コンソールで審査申請·修正してください。"
+ },
+ "link_type": {
+ "WL": "ウェブリンク",
+ "AL": "アプリリンク",
+ "DS": "配送追跡",
+ "BK": "ボットキーワード",
+ "MD": "メッセージ転送",
+ "AC": "チャネル追加",
+ "BC": "相談トーク転換",
+ "BT": "ボット転換",
+ "TN": "電話をかける",
+ "MP": "地図表示",
+ "P1": "画像セキュア送信",
+ "P2": "個人情報利用",
+ "P3": "ワンクリック決済"
+ },
+ "actions": {
+ "detail": "詳細"
+ },
+ "detail": {
+ "title": "テンプレート詳細",
+ "close": "閉じる",
+ "buttons": "ボタン",
+ "extra": "付加情報",
+ "category": "カテゴリ",
+ "code": "テンプレートコード",
+ "content": "テンプレート内容",
+ "emphasize_type": "テンプレート種別",
+ "image_upload": "画像添付",
+ "subtitle_field": "補助文句",
+ "title_field": "強調表記文句",
+ "type_image": "画像形",
+ "type_none": "基本形",
+ "type_text": "強調表記形"
+ }
+ }
+}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json
new file mode 100644
index 00000000..942bb45f
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/language-pack.json
@@ -0,0 +1,30 @@
+{
+ "identifier": "g7-plugin-sirsoft-message_bizppurio-ja",
+ "namespace": "g7",
+ "vendor": "sirsoft",
+ "name": {
+ "ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩",
+ "en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack",
+ "ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック"
+ },
+ "description": {
+ "ko": "G7 플러그인 (sirsoft-message_bizppurio) 일본어 언어팩 (번들)",
+ "en": "G7 plugin (sirsoft-message_bizppurio) Japanese language pack (bundled)",
+ "ja": "G7 プラグイン (sirsoft-message_bizppurio) 日本語 言語パック(バンドル)"
+ },
+ "version": "1.0.0",
+ "license": "MIT",
+ "scope": "plugin",
+ "target_identifier": "sirsoft-message_bizppurio",
+ "locale": "ja",
+ "locale_name": "Japanese",
+ "locale_native_name": "日本語",
+ "text_direction": "ltr",
+ "g7_version": ">=7.0.0-beta.4",
+ "requires": {
+ "target_version": null,
+ "depends_on_core_locale": true
+ },
+ "github_url": "",
+ "github_changelog_url": ""
+}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json
new file mode 100644
index 00000000..8d37fc29
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/manifest.json
@@ -0,0 +1,4 @@
+{
+ "name": "Bizppurio メッセージ発送",
+ "description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。"
+}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json
new file mode 100644
index 00000000..950f445d
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/notifications.json
@@ -0,0 +1,18 @@
+{
+ "bizppurio_balance_low": {
+ "definition": {
+ "name": "Bizssprrio残高不足",
+ "description": "Bizssprrio ウォレット残高が不足しており、SMS/アラートトーク送信に失敗した場合、管理者に送信されます"
+ },
+ "templates": {
+ "mail": {
+ "subject": "[{app_name}] Bizssprrio 残高が不足しています",
+ "body": "{name}様、Bizssprrio ウォレット残高が不足しているため、{channel_label} 送信に失敗しました (コード: {result_code})。チャージ後、送信が正常化されます。"
+ },
+ "database": {
+ "subject": "Bizssprrio残高不足",
+ "body": "Bizssprrio残高不足により、{channel_label} 送信に失敗しました (コード: {result_code})。"
+ }
+ }
+ }
+}
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json
new file mode 100644
index 00000000..db0ca18e
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/seed/permissions.json
@@ -0,0 +1,18 @@
+{
+ "sirsoft-message_bizppurio": {
+ "name": "ビズプリオ メッセージ発送",
+ "description": "ビズプリオ メッセージ発送プラグインが提供する権限"
+ },
+ "sirsoft-message_bizppurio.messaging": {
+ "name": "メッセージ発送",
+ "description": "メッセージ発送ドメイン権限 (閲覧·管理)"
+ },
+ "sirsoft-message_bizppurio.messaging.view": {
+ "name": "メッセージ閲覧",
+ "description": "発送履歴·アラートトークテンプレート閲覧 (モニタリング)"
+ },
+ "sirsoft-message_bizppurio.messaging.manage": {
+ "name": "メッセージ管理",
+ "description": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理"
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
new file mode 100644
index 00000000..eb97c84f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
@@ -0,0 +1,14 @@
+# Changelog
+
+이 프로젝트의 모든 주요 변경사항을 기록합니다.
+형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
+[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+
+## [1.0.0] - 2026-07-28
+
+### Added
+
+- 비즈뿌리오 연동 환경설정 화면과 문자(SMS/LMS)·카카오 알림톡 발송 채널을 추가했습니다. 회원가입·주문 등 코어 알림에 자동 연결되며, 검수/운영 환경을 구분해 운영합니다.
+- 설정 화면에서 카카오 알림톡 템플릿을 조회해 알림에 연결하고 실제로 발송할 수 있습니다. 템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행합니다. 게시판·이커머스 알림 설정 화면에서도 코어와 동일하게 연결할 수 있습니다. 발송 가능(승인) 상태가 아닌 템플릿을 연결하려 하면 저장을 거부하고 사유를 안내합니다.
+- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록하며, 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다. 요청 과다로 인한 일시적 발송 실패도 사유가 표시됩니다.
+- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치 시 자동 복원됩니다.
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/LICENSE b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE
new file mode 100644
index 00000000..4e94ed38
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/LICENSE
@@ -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.
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/README.md b/plugins/_bundled/sirsoft-message_bizppurio/README.md
new file mode 100644
index 00000000..d5ed4ac4
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/README.md
@@ -0,0 +1,243 @@
+# Bizppurio Messaging Plugin for G7
+
+
+
+
+
+
+
+
+비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다.
+
+G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다.
+
+[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 연동](#알림톡-템플릿-연동) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트)
+
+---
+
+## 주요 기능
+
+- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택
+- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영
+- 알림톡 미승인/미연결 시 문자로 자동 대체발송(옵션)
+- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용)
+- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록
+- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증
+- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용)
+- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시
+- 알림톡 템플릿 목록·상태·내용 조회 및 알림 연결 (템플릿 등록·검수는 비즈뿌리오 콘솔에서 진행)
+- 알림톡 템플릿이 카카오에서 삭제·차단·승인취소된 경우 "사용 불가 — 재연결 필요" 표시
+
+---
+
+## 요구 사항
+
+| 구분 | 항목 | 내용 |
+|------|------|------|
+| 플랫폼 | G7 | `>= 7.0.3` |
+| 플랫폼 | PHP | `^8.2` |
+| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 |
+| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) |
+| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 + 발송할 템플릿의 카카오 검수 승인 |
+
+> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다.
+
+---
+
+## 설치
+
+플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다.
+
+```text
+plugins/sirsoft-message_bizppurio
+```
+
+프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
+
+```bash
+npm install
+npm run build
+```
+
+그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널 기본 템플릿(제목·본문·수신자)이 알림 설정에 자동으로 채워집니다.
+
+---
+
+## 관리자 설정
+
+관리자 플러그인 설정 화면에서 비즈뿌리오 계정 정보를 입력합니다.
+
+| 설정 | 필수 여부 | 설명 |
+|------|:---:|------|
+| 검수 모드 | - | 활성화 시 실제 발송 없이 검수용으로만 동작. 발송 이력에 "검수" 라벨로 표시되어 실제 장애와 구분됨 |
+| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 필수 | 비즈뿌리오 계정 로그인 정보 |
+| API 키 | 운영 시 필수 | 비즈뿌리오 API 인증에 사용 |
+| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 |
+| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 |
+| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 |
+| 알림톡 내용 캐시 시간(분) | 선택 (기본 60) | 카카오 템플릿 내용 재사용 시간. 0이면 매 발송마다 최신 조회, [캐시 초기화] 버튼으로 즉시 반영 가능 |
+
+> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
+
+---
+
+## webhook(발송 결과 리포트) 등록
+
+비즈뿌리오가 발송 결과를 통보할 URL을 비즈뿌리오 콘솔에 등록해야 발송 결과(성공/실패/사유)가 발송 이력에 자동 기록됩니다.
+
+> **이 등록을 하지 않으면 발송 자체는 되지만 성공/실패 여부를 확인할 수 없습니다.**
+
+```text
+https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
+```
+
+정확한 URL은 관리자 플러그인 설정 화면의 환경설정 탭에서도 복사할 수 있습니다.
+
+---
+
+## 발송 흐름
+
+```text
+코어/모듈이 알림 발화 (예: 회원가입, 주문 완료)
+→ 알림 설정에 연결된 문자·알림톡 채널로 발송 작업 큐잉
+→ 알림톡: 카카오 승인 템플릿 사용, 미승인/미연결 시 문자로 대체발송(옵션)
+→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택
+→ 비즈뿌리오 발송 API 호출
+→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보
+→ 발송 이력에 결과 기록, 실패 시 사유·결과코드 함께 기록
+```
+
+일시적 오류(카카오 시스템 오류, 처리 지연, 게이트웨이 오류 등)로 실패한 경우 자동으로 재시도합니다. 지갑 잔액 부족·후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리와 함께 관리자에게 알림이 발송됩니다.
+
+---
+
+## 알림톡 템플릿 연동
+
+카카오 알림톡 템플릿의 **등록·수정·검수·상태변경은 비즈뿌리오 콘솔에서** 진행합니다. 이 플러그인의 설정 화면(알림톡 템플릿 탭)은 콘솔에 등록된 템플릿의 목록·상태·내용을 조회하고, 발송가능(승인) 상태의 템플릿을 알림에 연결하는 역할만 담당합니다.
+
+| 상태 | 발송 가능 | 설명 |
+|:---:|:---:|------|
+| 발송가능 | ✅ | 카카오 검수 승인 완료, 알림에 연결해 발송 가능 |
+| 검수중 | ❌ | 카카오 검수 진행 중 |
+| 반려 | ❌ | 카카오 검수 반려, 콘솔에서 재신청 필요 |
+| 미검수 | ❌ | 콘솔에 등록만 되고 검수 신청 전 상태 |
+| 중지 | ❌ | 사용 중지된 템플릿 |
+
+알림에 연결한 템플릿이 이후 카카오에서 삭제·차단되거나 승인이 취소되면, 알림 설정 화면의 해당 알림에 "사용 불가 — 재연결 필요"가 표시됩니다. 이 경우 알림톡 템플릿 탭에서 다른 승인 템플릿으로 다시 연결해야 합니다.
+
+---
+
+## 발송 결과 코드
+
+비즈뿌리오/카카오가 반환하는 결과 코드는 4가지로 분류되어 처리됩니다.
+
+| 분류 | 처리 방침 |
+|:---:|------|
+| 성공 | 발송 완료 |
+| 재시도 (일시 오류) | 자동 재시도 대상 (예: 카카오 시스템 오류, 처리 지연, 게이트웨이 오류) |
+| 잔액 부족 | 즉시 실패 처리 + 관리자 자체 알림 |
+| 영구 실패 | 즉시 실패 처리, 재시도하지 않음 |
+
+주요 코드 예시:
+
+| 코드 | 분류 | 사유 |
+|:---:|------|------|
+| `1000` `4100` `6600` `7000` | 성공 | 발송/리포트 성공 |
+| `9070` | 잔액 부족 | 잔액 부족(문자) |
+| `9071` | 잔액 부족 | 후불 한도 초과 |
+| `7436` | 잔액 부족 | 지갑 잔액 부족(알림톡) |
+| `4400` | 영구 실패 | 음영 지역 |
+| `7103` | 영구 실패 | 발신 프로필 키 무효 |
+
+발송 이력 화면에는 `사유 (코드)` 형식(예: "음영 지역 (4400)")으로 표시됩니다. 전체 코드 목록은 `lang/ko/result_codes.php` / `lang/en/result_codes.php`에 정의되어 있으며, lang에 없는 코드는 코드만 표시됩니다.
+
+---
+
+## 가용 훅 (Hook)
+
+다른 모듈이나 플러그인에서 아래 훅에 연결해 잔액부족 상황을 확장 처리할 수 있습니다.
+
+### 액션 훅
+
+| 훅 이름 | 시점 | 인수 |
+|------|------|------|
+| `sirsoft-message_bizppurio.balance.low` | 잔액 부족·한도 초과로 발송 실패 시 (쿨다운 내 최초 1회) | `string $resultCode, string $channel` |
+
+### 훅 등록 예시
+
+```php
+use App\Extension\HookManager;
+
+HookManager::addAction(
+ 'sirsoft-message_bizppurio.balance.low',
+ function (string $resultCode, string $channel) {
+ // 예: 잔액 부족 시 Slack으로도 별도 알림
+ SlackNotifier::send("비즈뿌리오 잔액 부족: 채널={$channel}, 코드={$resultCode}");
+ },
+ priority: 10
+);
+```
+
+`$resultCode`는 잔액 부족(`9070` 문자 / `7436` 알림톡) 또는 후불 한도 초과(`9071`) 코드입니다. 이 훅은 관리자 자체 알림(잔액부족/후불한도초과 안내)을 발화하는 지점과 동일하며, 채널별 쿨다운(기본 3600초) 동안 한 번만 실행됩니다.
+
+---
+
+## API
+
+전체 엔드포인트 레퍼런스는 [docs/api/README.md](docs/api/README.md)를 참고하세요.
+
+| 문서 | 내용 |
+|------|------|
+| [webhook.md](docs/api/webhook.md) | 비즈뿌리오 발송 결과 리포트 수신 |
+| [templates.md](docs/api/templates.md) | 알림톡 템플릿 조회 |
+| [alimtalk-templates.md](docs/api/alimtalk-templates.md) | 알림톡 템플릿 관리자 API |
+| [notification-bindings.md](docs/api/notification-bindings.md) | 알림-템플릿 연결 |
+| [dispatch-results.md](docs/api/dispatch-results.md) | 발송 결과 조회 |
+| [report.md](docs/api/report.md) | 발송 결과 리포트 URL 조회 |
+
+### 권한
+
+| 권한 | 설명 |
+|------|------|
+| `sirsoft-message_bizppurio.messaging.view` | 발송 이력, 알림톡 템플릿, 발송 결과 조회 |
+| `sirsoft-message_bizppurio.messaging.manage` | 알림톡 템플릿 연결, 캐시 초기화 등 관리 작업 |
+
+---
+
+## 삭제 시 동작
+
+플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다.
+
+---
+
+## 보안 및 운영 참고
+
+- 비즈뿌리오 비밀번호, API 키, 알림톡 발신프로필 키는 외부에 노출하지 마세요. 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
+- 운영 모드 전환 전 검수 모드에서 문자·알림톡 발송 흐름을 먼저 확인하세요. 운영 모드는 실제 발송과 비용이 발생합니다.
+- webhook URL을 비즈뿌리오 콘솔에 등록하지 않으면 발송 결과 확인이 불가능합니다.
+- 검수와 운영은 별도의 비즈뿌리오 계정 사용을 권장합니다.
+- 지갑 잔액/후불 한도를 주기적으로 확인하세요. 부족 시 관리자 알림이 발송되지만, 알림 자체도 같은 채널(문자/알림톡)을 사용하지 않는 별도 채널(예: 사이트 내 알림, 메일)로 함께 받는 것을 권장합니다.
+
+---
+
+## 테스트
+
+플러그인을 G7 프로젝트에 배치한 뒤 G7 루트에서 PHP 테스트를 실행합니다.
+
+```bash
+php artisan test plugins/sirsoft-message_bizppurio/tests
+```
+
+프론트엔드 테스트와 빌드는 플러그인 디렉토리에서 실행합니다.
+
+```bash
+npm install
+npm run test:run
+npm run build
+```
+
+---
+
+## 라이선스
+
+MIT
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/components.json b/plugins/_bundled/sirsoft-message_bizppurio/components.json
new file mode 100644
index 00000000..42c0ba08
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/components.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "identifier": "sirsoft-message_bizppurio",
+ "version": "1.0.0",
+ "components": {
+ "basic": [],
+ "composite": [],
+ "layout": []
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/composer.json b/plugins/_bundled/sirsoft-message_bizppurio/composer.json
new file mode 100644
index 00000000..22992fd2
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/composer.json
@@ -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/", "./"]
+ }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json
new file mode 100644
index 00000000..9fab46fb
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/config/settings/defaults.json
@@ -0,0 +1,23 @@
+{
+ "_meta": {
+ "version": "1.0.0",
+ "description": "비즈뿌리오 메시징 플러그인 환경설정 기본값 및 프론트엔드 스키마. 크리덴셜(password/api_key/sender_key)은 sensitive + expose:false 로 프론트 노출을 차단한다. 관리자 설정 화면은 코어 /api/admin/plugins/{id}/settings 로 직접 조회하므로 window.G7Config 노출이 불필요하여 전 필드 expose:false."
+ },
+ "defaults": {
+ "is_test_mode": true,
+ "bizppurio_id": "",
+ "password": "",
+ "api_key": "",
+ "sender_number": "",
+ "sender_key": "",
+ "balance_low_notify_cooldown": 3600
+ },
+ "frontend_schema": {
+ "is_test_mode": { "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 }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php
new file mode 100644
index 00000000..49a4ec70
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000001_create_bizppurio_dispatches_table.php
@@ -0,0 +1,73 @@
+bigIncrements('id')->comment('발송 이력 PK');
+ $table->string('refkey', 32)->comment('우리 부여 키 (webhook 매칭용, UTF-8 최대 32byte)');
+ $table->string('messagekey', 64)->nullable()->comment('비즈뿌리오 부여 키 (발송 응답 messagekey)');
+ $table->string('channel', 20)->comment('발송 채널: sms / lms / alimtalk (DispatchChannel enum)');
+ $table->string('media', 10)->nullable()->comment('webhook MEDIA — 실제 발송 유형: SMS / LMS / KAT(알림톡)');
+ $table->string('to_number', 20)->comment('수신 전화번호 (숫자만)');
+ $table->string('to_name', 100)->nullable()->comment('수신자명 (발송 시점 스냅샷)');
+ $table->foreignId('to_user_id')->nullable()->comment('회원이면 users FK, 비회원이면 null')
+ ->constrained('users', indexName: 'bizppurio_dispatch_user_fk')->nullOnDelete();
+ $table->text('content')->comment('발송 본문(SMS) 또는 템플릿 참조(알림톡)');
+ $table->json('request_payload')->nullable()->comment('실제 비즈뿌리오 API 전송 요청 payload (개인식별 정보 제외 — to/refkey/type/본문 텍스트는 다른 컬럼과 중복이라 제외, account/from/senderkey/templatecode/버튼 등 요소만 저장)');
+ $table->string('notification_type', 100)->nullable()->comment('코어 notification_definitions.type 참조 (어느 알림). 수동발송 시 null');
+ $table->unsignedBigInteger('notification_log_id')->nullable()->comment('코어 notification_logs.id 연결 표식 (A-2). 코어 알림 발송 이력 화면에서 결과를 이 행에 붙이기 위한 매칭 키. 코어 테이블 무수정 — 연결 표식은 비즈뿌리오 쪽에만 둔다');
+ $table->string('status', 20)->default('pending')->comment('발송 상태: pending / sent / success / failed (DispatchStatus enum)');
+ $table->string('result_code', 10)->nullable()->comment('결과 코드 (발송응답 or 리포트)');
+ $table->string('result_message', 255)->nullable()->comment('결과 사유 원본 (result_codes lang 해석 전)');
+ $table->string('fallback_status', 20)->nullable()->comment('대체발송 결과: 성공/실패/없음 (webhook TELRES)');
+ $table->string('source', 10)->default('auto')->comment('발송 출처: auto / manual / bulk (DispatchSource enum, 1차 auto)');
+ $table->boolean('is_test_mode')->nullable()->comment('발송 시점 검수 모드 여부 (null=컬럼 신설 이전 이력)');
+ $table->timestamp('sent_at')->nullable()->comment('발송 시각');
+ $table->timestamp('reported_at')->nullable()->comment('webhook 리포트 수신 시각 (replay 멱등 판정)');
+ $table->json('raw_payload')->nullable()->comment('webhook 원본 페이로드');
+ $table->timestamps();
+
+ $table->unique('refkey', 'bizppurio_dispatch_refkey_unique');
+ $table->index('channel', 'bizppurio_dispatch_channel_idx');
+ $table->index('status', 'bizppurio_dispatch_status_idx');
+ $table->index('notification_type', 'bizppurio_dispatch_notif_type_idx');
+ $table->index('sent_at', 'bizppurio_dispatch_sent_at_idx');
+ $table->index('to_user_id', 'bizppurio_dispatch_user_idx');
+ $table->index(['status', 'sent_at'], 'bizppurio_dispatch_status_sent_idx');
+ $table->index('notification_log_id', 'bizppurio_dispatch_notif_log_idx');
+ });
+
+ if (DB::getDriverName() === 'mysql') {
+ Schema::table('bizppurio_dispatches', function (Blueprint $table) {
+ $table->comment('비즈뿌리오 문자·알림톡 발송 이력');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('bizppurio_dispatches');
+ }
+};
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php
new file mode 100644
index 00000000..e22d555f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php
@@ -0,0 +1,51 @@
+bigIncrements('id')->comment('연결 설정 PK');
+ $table->string('notification_type', 100)->comment('코어 notification_definitions.type (연결 대상 알림)');
+ $table->string('channel', 20)->default('alimtalk')->comment('채널 (1차 alimtalk 고정)');
+ $table->string('template_code', 50)->comment('연결한 카카오 알림톡 템플릿 코드');
+ $table->string('template_name', 255)->comment('템플릿 이름 (사람 식별 + 고아 감지용 스냅샷)');
+ $table->boolean('fallback_sms_enabled')->default(false)->comment('개별 대체발송 ON/OFF (실패 시 SMS/LMS 대체)');
+ $table->boolean('is_active')->default(true)->comment('연동 활성 여부');
+ $table->timestamps();
+
+ $table->unique(['notification_type', 'channel'], 'bizppurio_binding_type_channel_unique');
+ });
+
+ if (DB::getDriverName() === 'mysql') {
+ Schema::table('bizppurio_notification_bindings', function (Blueprint $table) {
+ $table->comment('비즈뿌리오 이벤트↔알림톡 템플릿 연결 설정');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('bizppurio_notification_bindings');
+ }
+};
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js
new file mode 100644
index 00000000..b29c9bd6
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js
@@ -0,0 +1 @@
+var SirsoftMessageBizppurio=function(r){"use strict";const i={},t="sirsoft-message_bizppurio",o=window.G7Core?.createLogger?.(`Plugin:${t}`)??{log:(...e)=>console.log(`[Plugin:${t}]`,...e),warn:(...e)=>console.warn(`[Plugin:${t}]`,...e),error:(...e)=>console.error(`[Plugin:${t}]`,...e)};function a(e){Object.entries(i).forEach(([n,c])=>{e.registerHandler(`${t}.${n}`,c,{category:"plugin",source:t})}),o.log(`${Object.keys(i).length} handler(s) registered:`,Object.keys(i).map(n=>`${t}.${n}`))}function g(e){const n=window.G7Core?.getActionDispatcher?.();if(n){a(n);return}if(!e){o.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let c=0;const l=50,u=()=>{const d=window.G7Core?.getActionDispatcher?.();if(d){a(d);return}++c<=l?setTimeout(u,100):o.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};u()}function s(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>g(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();g(!e)}}return s(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:t,handlers:Object.keys(i),initPlugin:s}),r.initPlugin=s,Object.defineProperty(r,Symbol.toStringTag,{value:"Module"}),r}({});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md
new file mode 100644
index 00000000..d98fadfc
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md
@@ -0,0 +1,20 @@
+# API 레퍼런스 문서 목차
+
+> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반).
+> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
+
+
+- **문서 수**: 7 · **엔드포인트 수**: 13
+
+| 문서 | 도메인 | 엔드포인트 |
+| --- | --- | --- |
+| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 4 |
+| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 |
+| [notification-bindings.md](notification-bindings.md) | `notification-bindings` | 3 |
+| [report.md](report.md) | `report` | 1 |
+| [templates.md](templates.md) | `templates` | 1 |
+| [token.md](token.md) | `token` | 1 |
+| [webhook.md](webhook.md) | `webhook` | 1 |
+
+
+
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md
new file mode 100644
index 00000000..4bff9007
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md
@@ -0,0 +1,229 @@
+# 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) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.index`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@index`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+| 파라미터 | 위치 | 타입 | 필수 | 제약 | 설명 |
+| --- | --- | --- | --- | --- | --- |
+| status | query | string | 아니오 | max 30 | kapi `templateStatus` 필터 값(어휘는 kapi 정의를 따름) |
+| keyword | query | string | 아니오 | max 50 | 템플릿명/코드 검색어 |
+| page | query | integer | 아니오 | min 1 | 페이지 번호(기본 1) |
+| count | query | integer | 아니오 | min 1 | 페이지당 건수(기본값은 서버 설정) |
+
+**요청 예시**
+
+```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` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+
+### GET /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` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+
+### GET /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` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{templateCode}
+
+- **라우트명**: `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` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+| 404 | Not Found | path 파라미터에 해당하는 리소스가 없는 경우 |
+
+
+
+**설명**
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.cache.clear`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@clearCache`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+| 필드 | 타입 | 설명 |
+| --- | --- | --- |
+| `cleared` | integer | 초기화한 캐시 키 수(연결된 고유 알림톡 템플릿 코드 수) |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
+ "data": { "cleared": 3 }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+
+**설명**
+
+발송 시 알림톡은 카카오 승인 템플릿의 실제 내용(본문·버튼·요소)을 카카오 상세조회로 가져와 채우며, 그 결과를 template_code 단위로 캐시한다(기본 1시간, 환경설정 `template_cache_ttl` 로 조정, 0이면 캐시 끔). 카카오 콘솔에서 템플릿 내용을 방금 변경해 캐시 만료를 기다리지 않고 즉시 반영하고 싶을 때 이 엔드포인트로 캐시를 비운다. 연결(binding)된 모든 알림톡 템플릿의 캐시를 초기화하며, 다음 발송에서 최신 내용으로 재조회된다. 카카오 API 를 호출하지 않고 로컬 캐시만 비우므로 rate limit 에 영향을 주지 않는다. 관리자 화면(알림톡 템플릿 탭)의 "내용 캐시 초기화" 버튼이 이 엔드포인트를 호출한다.
+
+
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md
new file mode 100644
index 00000000..81b4f96b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md
@@ -0,0 +1,329 @@
+# Dispatch Results API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Dispatch Results 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
+3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.lookup`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@lookup`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| notification_log_ids | body | array | 아니오 | max 100 | notification log 식별자 배열 |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/lookup HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "notification_log_ids": [
+ "예시값"
+ ]
+}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| results | array | `[]` | |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "messages.success",
+ "data": {
+ "results": []
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(layout_extensions overlay)이 소비하는 조회 API 다.
+현재 페이지에 표시된 코어 알림 로그 id 배열(`notification_log_ids`, 최대 100)을 넘기면, 그 로그에
+연결된 비즈뿌리오 발송 결과를 **로그 id 를 키로 하는 맵**으로 한 번에 돌려준다(행마다 개별 호출하지
+않아 N+1 을 피한다). 코어 알림 로그 테이블은 수정하지 않으며, 연결 표식은 비즈뿌리오 쪽
+(`bizppurio_dispatches.notification_log_id`)에만 둔다.
+
+매칭되지 않는 로그 id(메일·사이트내알림 등 비-비즈뿌리오 발송)는 결과 맵에서 제외된다 — 화면에서는
+그 행의 결과 컬럼이 빈 셀이 된다. 결과에는 전화번호 등 민감정보를 포함하지 않는다(결과 컬럼은 상태·
+사유만 표시).
+
+**응답 필드** (`data.results` — 키는 `notification_log_id`, 값은 결과 객체)
+
+| 필드 | 타입 | 용도 |
+| --- | --- | --- |
+| status | string\|null | 발송 상태: `sent` / `success` / `failed` (DispatchStatus) |
+| status_label | string\|null | 로케일 상태 라벨 (예: "성공", "실패", "발송중") |
+| result_code | string\|null | 결과 코드 (발송응답 또는 webhook 리포트). 리포트 미수신 시 null |
+| result_label | string\|null | `사유 (코드)` 표시 라벨 (예: "음영 지역 (4400)"). 코드 없으면 null |
+| is_low_balance | boolean | 잔액 부족(9070 문자 / 7436 알림톡) 여부 |
+| fallback_status | string\|null | SMS 대체발송 결과 (webhook TELRES). 없으면 null |
+| channel | string\|null | 발송 채널: `sms` / `lms` / `alimtalk` |
+| content | string\|null | 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 `notification_logs.body`(대체발송용 코어 템플릿 값)와 달리 카카오 승인 템플릿의 실제 내용이므로, 화면은 이 값을 채널별로 구분해 별도 노출한다 |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "성공",
+ "data": {
+ "results": {
+ "128": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "4400",
+ "result_label": "음영 지역 (4400)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ }
+ }
+ }
+}
+```
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.dispatch-results.recent`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\DispatchResultController@recent`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| results | object | `{"21":{"status":"pending","status_label":"대기","result_cod…` | |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "messages.success",
+ "data": {
+ "results": {
+ "21": {
+ "status": "pending",
+ "status_label": "대기",
+ "result_code": null,
+ "result_label": null,
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "alimtalk"
+ },
+ "20": {
+ "status": "success",
+ "status_label": "성공",
+ "result_code": "7000",
+ "result_label": "성공 (7000)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "alimtalk"
+ },
+ "19": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "7206",
+ "result_label": "검수되지 않은 템플릿 (7206)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "alimtalk"
+ },
+ "18": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "7436",
+ "result_label": "지갑 잔액 부족(알림톡) (7436)",
+ "is_low_balance": true,
+ "fallback_status": "실패",
+ "channel": "alimtalk"
+ },
+ "17": {
+ "status": "success",
+ "status_label": "성공",
+ "result_code": "7000",
+ "result_label": "성공 (7000)",
+ "is_low_balance": false,
+ "fallback_status": "성공",
+ "channel": "alimtalk"
+ },
+ "16": {
+ "status": "success",
+ "status_label": "성공",
+ "result_code": "7000",
+ "result_label": "성공 (7000)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "alimtalk"
+ },
+ "15": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "6603",
+ "result_label": "음영 지역 (6603)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "lms"
+ },
+ "14": {
+ "status": "success",
+ "status_label": "성공",
+ "result_code": "6600",
+ "result_label": "성공 (6600)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "lms"
+ },
+ "13": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "9999",
+ "result_label": "9999",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ },
+ "12": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "9070",
+ "result_label": "잔액 부족(문자) (9070)",
+ "is_low_balance": true,
+ "fallback_status": null,
+ "channel": "sms"
+ },
+ "11": {
+ "status": "success",
+ "status_label": "성공",
+ "result_code": "4100",
+ "result_label": "성공 (4100)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ },
+ "10": {
+ "status": "pending",
+ "status_label": "대기",
+ "result_code": null,
+ "result_label": null,
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ },
+ "9": {
+ "status": "sent",
+ "status_label": "발송중",
+ "result_code": null,
+ "result_label": null,
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ },
+ "8": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "4410",
+ "result_label": "잘못된 번호 (4410)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ },
+ "7": {
+ "status": "failed",
+ "status_label": "실패",
+ "result_code": "4400",
+ "result_label": "음영 지역 (4400)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ },
+ "6": {
+ "status": "success",
+ "status_label": "성공",
+ "result_code": "4100",
+ "result_label": "성공 (4100)",
+ "is_low_balance": false,
+ "fallback_status": null,
+ "channel": "sms"
+ }
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md
new file mode 100644
index 00000000..4ea771f9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md
@@ -0,0 +1,196 @@
+# Notification Bindings API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Notification Bindings 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
+3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.index`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@index`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| bindings | array | `[]` | |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "messages.success",
+ "data": {
+ "bindings": []
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+알림 설정 화면의 알림톡 탭이 소비하는 목록이다. 코어 알림 정의 중 채널에 `alimtalk` 을 포함하는 활성 알림 전체를, 연결된 알림톡 템플릿(binding)과 조인해 반환한다. 미연결 알림은 `is_bound=false` 이고 `template_code`/`template_name` 은 null, `fallback_sms_enabled` 는 false 다. `variables` 는 코어 알림 정의의 변수 목록으로, 편집 모달의 "제공 변수" 안내에 쓰인다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.store`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@store`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| notification_type | body | string | 예 | max 100 | |
+| template_code | body | string | 아니오 | max 50 | |
+| template_name | body | string | 아니오 | max 255 | template 이름 (식별자) |
+| fallback_sms_enabled | body | boolean | 아니오 | — | |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "notification_type": "예시값",
+ "template_code": "예시값",
+ "template_name": "예시 이름",
+ "fallback_sms_enabled": true
+}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| bindings | object | `{"실측 예시값":{"notification_type":"실측 예시값","template_code":"…` | |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "messages.binding.saved",
+ "data": {
+ "bindings": {
+ "실측 예시값": {
+ "notification_type": "실측 예시값",
+ "template_code": "실측 예시값",
+ "template_name": "실측 예시값",
+ "fallback_sms_enabled": true
+ }
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+| 422 | Unprocessable Entity | `template_code` 가 카카오 승인 상태(RDY/ACT)가 아니거나, 카카오 승인 목록 조회 자체가 실패(자격증명 미설정·장애)한 경우 (`error.kakao_message`/`error.result_code`) |
+
+
+
+**설명**
+
+알림에 알림톡 템플릿을 연결(생성/갱신)한다. 알림톡 탭 편집 모달의 [저장] 이 호출한다. `(notification_type, alimtalk)` 당 1개 연결이므로, 이미 연결이 있으면 갱신(upsert)된다. 저장은 코어 알림 설정 저장 버튼과 무관하게 이 API 로 직접 수행되어 코어 우회가 없다(§6-2). 저장 전 카카오 승인 상태(RDY/ACT)를 서버측에서 재검증하며, 미승인 코드거나 카카오 조회 자체가 실패하면 422 로 거부한다(편집 모달 드롭다운의 승인 템플릿 필터는 화면 단계일 뿐이라, 이를 우회한 직접 API 호출로 미승인 템플릿이 저장되는 것을 막기 위함). 연결 해제(`template_code` 빈 값)는 이 검증을 거치지 않는다.
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.notification-bindings.approved-templates`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\NotificationBindingController@approvedTemplates`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+연동 편집 모달의 "연결 템플릿" 드롭다운을 채우는 소스다. 카카오 템플릿 목록 중 serviceStatus 가 RDY(발송전)·ACT(정상)인 승인 템플릿만 반환한다. 자격증명(bizId·apiKey·senderKey) 미설정이거나 kapi 조회 실패 시 422 로 카카오 사유를 그대로 전달한다.
+
+
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md
new file mode 100644
index 00000000..3f8a4284
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/report.md
@@ -0,0 +1,80 @@
+# Report API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Report 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
+3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/report-url
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.report.url`
+- **인증/권한**: `auth:sanctum` + `permission:core.plugins.read`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/report-url HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| url | string | `http://g7-issue.eh.test/api/plugins/s…` | |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "data": {
+ "url": "https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook"
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`core.plugins.read`)이 없는 경우 |
+
+
+
+**설명**
+
+비즈뿌리오 발송 결과(리포트)를 수신할 콜백 URL을 관리자 설정 페이지에 표시하기 위해 반환하는 엔드포인트입니다. 관리자 환경설정 화면(`admin/plugin_settings.json`)의 `report_url` data_source 가 이 값을 조회해 "리포트 수신 설정" 카드에 readonly 로 표시하고, 운영자가 그대로 복사해 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 URL PUSH 수신 주소로 등록합니다.
+
+URL 은 `url()` 헬퍼가 아니라 `config('app.url')` 을 기준으로 조합합니다 — 리버스 프록시 뒤 PHP-FPM 환경에서 요청 host 가 `localhost` 로 떨어질 수 있어, 운영자가 관리하는 설정값을 신뢰 소스로 삼아 항상 정식 도메인을 노출합니다.
+
+관리자 인증(`auth:sanctum`)과 `core.plugins.read` 권한이 필요하며, 토큰 누락·만료는 401, 권한 부족은 403 으로 응답합니다.
+
+※ 실제 리포트 수신 처리(`POST /webhook`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다.
+
+
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md
new file mode 100644
index 00000000..6f360c96
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md
@@ -0,0 +1,75 @@
+# Templates API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Templates 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
+3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.readiness`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/templates-readiness HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_단건 응답: `data` 객체의 필드._
+
+| 필드 | 타입 | 실측 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| api_key_set | boolean | `false` | |
+| sender_key_set | boolean | `false` | |
+| ready | boolean | `false` | |
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "data": {
+ "api_key_set": "{MASKED}",
+ "sender_key_set": false,
+ "ready": false
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md
new file mode 100644
index 00000000..0180514e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/token.md
@@ -0,0 +1,74 @@
+# Token API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 Token(연결 확인) 엔드포인트 레퍼런스입니다
+2. POST /admin/token/check — 저장된 계정/비밀번호로 즉시 재인증(캐시 우회)
+3. 성공 시 200 + 새 토큰을 캐시에 반영, 실패 시 422 + errors 에 비즈뿌리오 원문 사유(bizppurio_message) + result_code
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/token/check
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.token.check`
+- **인증/권한**: `auth:sanctum` + `admin` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+_요청 파라미터 없음. 저장된 플러그인 설정(`bizppurio_id`, `password`)을 사용합니다._
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/token/check HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+_성공 응답은 `data` 없이 메시지만 반환합니다._
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+```
+
+```json
+{
+ "success": true,
+ "message": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다."
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 422 | Unprocessable Entity | 자격증명 미설정(계정/비밀번호 공란) 또는 비즈뿌리오 인증 실패 — `errors.bizppurio_message` 에 비즈뿌리오 실패 사유 원문, `errors.result_code` 에 비즈뿌리오 응답 결과코드(있으면) 동반. 비즈뿌리오 서버 연결 자체가 실패(타임아웃·DNS 등)한 경우도 422(연결 실패 안내 메시지, `result_code` 없음) |
+
+
+
+**설명**
+
+관리자가 설정 화면에서 저장한 비즈뿌리오 아이디·비밀번호가 실제로 유효한지 그 자리에서 확인하는 "연결 확인" 버튼이 호출하는 엔드포인트입니다. `BizppurioTokenService::verifyCredentials()` 가 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출해 재검증하며, 성공 시 새로 발급된 토큰을 캐시(TTL 23시간)에 반영해 확인 직후의 발송이 이 토큰을 그대로 재사용하게 합니다(불필요한 재발급 방지).
+
+실패 시 `BizppurioApiException` 을 422 로 변환합니다 — 응답 `message` 는 고정 안내(`token_check.failed`)이고, 비즈뿌리오가 준 실패 사유 원문(응답 `description` 이 있으면 `token_issue_failed_with_reason` 형태로 조립된 문장)은 관리자 전용 진단 정보로 `errors.bizppurio_message` 에 담습니다(예외 원문을 메시지 키 자리에 전달하지 않는 예외→응답 매핑 규정). 응답 `errors.result_code` 는 비즈뿌리오 결과코드(예: `3007`)이며, HTTP 전송 자체가 실패한 경우 `null` 입니다.
+
+비즈뿌리오 서버 자체에 연결할 수 없는 경우(타임아웃·DNS 실패 등)는 `BizppurioApiException` 이 아닌 `ConnectionException` 으로 던져지므로 별도 catch 하여 `error.connection_failed` 메시지와 함께 422 로 응답합니다(`result_code` 없이 500 대신 매끄러운 실패 안내).
+
+프론트 화면(`admin/plugin_settings.json` "연결 확인" 필드)은 저장하지 않은 변경사항(`_local.hasChanges`)이 있으면 이 API 를 호출하지 않고 "변경사항을 먼저 저장해주세요" toast 만 표시합니다 — 저장 전 값으로 확인하면 실제 저장된 자격증명과 다른 결과가 나올 수 있기 때문입니다.
+
+조회가 아닌 재인증(쓰기 성격의 외부 API 호출)이므로 `messaging.view` 가 아닌 `messaging.manage` 권한을 요구합니다.
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md
new file mode 100644
index 00000000..f8cea569
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md
@@ -0,0 +1,86 @@
+# Webhook API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 실제 API 호출로 실측한 Webhook 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
+3. 응답 필드의 예시값·응답 예시 JSON 은 실제 호출 응답에서 관측된 값입니다
+4. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+5. 설명(TODO) 칸은 사람이 채웁니다
+```
+
+---
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/webhook
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.webhook`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\BizppurioWebhookController@handle`
+- **인증/권한**: 공개 (인증 불필요)
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| DEVICE | body | string | 아니오 | max 20 | |
+| CMSGID | body | string | 아니오 | max 64 | |
+| MSGID | body | string | 아니오 | max 64 | |
+| PHONE | body | string | 아니오 | max 20 | |
+| MEDIA | body | string | 아니오 | max 10 | |
+| RESULT | body | string | 예 | max 10 | |
+| REFKEY | body | string | 예 | max 32 | |
+| TELRES | body | string | 아니오 | max 10 | |
+| KAORES | body | string | 아니오 | max 10 | |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/webhook HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+
+{
+ "DEVICE": "예시값",
+ "CMSGID": "예시값",
+ "MSGID": "예시값",
+ "PHONE": "010-1234-5678",
+ "MEDIA": "예시값",
+ "RESULT": "예시값",
+ "REFKEY": "예시값",
+ "TELRES": "예시값",
+ "KAORES": "예시값"
+}
+```
+
+**응답 필드** (`data` 내부)
+
+
+
+**응답 예시**
+
+
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 (`error.errors` 에 필드별 메시지) |
+
+
+
+**설명**
+
+비즈뿌리오가 문자·알림톡 발송 결과를 URL PUSH 로 통보하는 리포트 수신 엔드포인트다. 운영자가 이 주소를 비즈뿌리오에 등록하면(환경설정 화면의 리포트 수신 주소), 발송 후 결과가 이 엔드포인트로 전송된다.
+
+- **인증**: 코어 토큰/IDV 미들웨어를 라우트 레벨에서 제외하고, 인증을 IP 화이트리스트로 대체한다. 화이트리스트 밖 IP 는 403. IP 화이트리스트는 `plugin.php::getMiddleware()` 에서 이 라우트명(`api.plugins.sirsoft-message_bizppurio.webhook`)으로 self-gate 선언하며, 코어 게이트가 요청 시점에 부착한다(라우트 파일 직접 부착 아님).
+- **처리**: `REFKEY` 로 발송 이력을 조회한다. 없으면(위조/미매칭) 200 으로 흡수한다. 이미 리포트가 반영된 이력(`reported_at` 존재)이면 replay 로 판정해 멱등 처리한다. 그 외에는 `RESULT` 코드를 분류(성공/실패/잔액부족)해 상태를 전이하고 `media`·`fallback_status`·`raw_payload`·`reported_at` 을 기록한다.
+- **잔액부족**: `RESULT` 가 9070(문자)/7436(알림톡)이면 이력을 실패로 뒤집고 관리자에게 자체 알림을 1회 발송한다.
+- 응답은 항상 200 이다(비즈뿌리오가 실패 응답을 재전송하지 않도록). replay 멱등이 중복 처리를 막는다.
+
+
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php
new file mode 100644
index 00000000..d6b0cae2
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/activity_log.php
@@ -0,0 +1,10 @@
+ [],
+ 'description' => [],
+];
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php
new file mode 100644
index 00000000..29dcbd63
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php
@@ -0,0 +1,113 @@
+ [
+ 'sms' => 'SMS',
+ 'lms' => 'LMS',
+ 'alimtalk' => 'Alimtalk',
+ ],
+
+ // 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용)
+ 'channel_group' => [
+ 'text' => 'SMS',
+ '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',
+ ],
+
+ // 알림 채널 메타 (core.notification.filter_available_channels)
+ 'channels' => [
+ 'source_label' => 'Bizppurio',
+ 'sms' => [
+ 'name' => 'SMS/LMS Text',
+ 'description' => 'Send notifications as SMS/LMS text messages via Bizppurio.',
+ ],
+ 'alimtalk' => [
+ 'name' => 'Kakao Alimtalk',
+ 'description' => 'Send notifications as Kakao Alimtalk messages via Bizppurio.',
+ ],
+ ],
+
+ // 채널 준비 상태 사유 (core.notification.channel_readiness)
+ 'readiness' => [
+ 'sms_credentials_missing' => 'Please set the Bizppurio ID and password.',
+ 'sms_sender_number_missing' => 'Please set the sender number.',
+ 'alimtalk_api_key_missing' => 'Please set the Kakao management API key.',
+ 'alimtalk_sender_key_missing' => 'Please set the Alimtalk sender profile key.',
+ ],
+
+ // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용)
+ 'settings' => [
+ 'bizppurio_id_attribute' => 'Bizppurio ID',
+ 'password_attribute' => 'Password',
+ 'sender_number_attribute' => 'Sender Number',
+ ],
+
+ // webhook(URL PUSH) 리포트 수신
+ 'webhook' => [
+ 'received' => 'Report received.',
+ ],
+
+ // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job)
+ 'error' => [
+ 'credentials_missing' => 'Please set the Bizppurio ID and password first.',
+ 'token_issue_failed' => 'Failed to issue the Bizppurio authentication token.',
+ 'token_issue_failed_with_reason' => 'Failed to issue the Bizppurio authentication token. (:reason)',
+ 'send_failed' => 'Failed to send the message.',
+ 'send_retryable' => 'Message delivery temporarily failed. (code: :code)',
+ 'invalid_response' => 'Unable to parse the Bizppurio response.',
+ 'connection_failed' => 'Unable to connect to the Bizppurio server. Please try again later.',
+ '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.',
+ 'template_not_sendable' => 'This template is not in a sendable (approved) state. (code: :code)',
+ ],
+
+ // Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log)
+ 'send_skipped' => [
+ 'alimtalk_binding_missing' => 'Skipped sending: no alimtalk template is bound. (notification type: :type)',
+ 'alimtalk_kakao_content_unavailable' => 'Skipped sending: failed to fetch the approved Kakao template content. (notification type: :type)',
+ 'sms_template_missing' => 'Skipped sending: no SMS template found. (notification type: :type)',
+ 'recipient_phone_missing' => 'Skipped sending: recipient phone number is missing. (notification type: :type)',
+ 'message_body_empty' => 'Skipped sending: message body is empty. (notification type: :type)',
+ ],
+
+ // Notification-to-alimtalk template binding (NotificationBindingController responses)
+ 'binding' => [
+ 'saved' => 'Alimtalk binding saved.',
+ 'removed' => 'Alimtalk binding removed.',
+ ],
+
+ // Dispatch template content cache (AlimtalkTemplateController::clearCache response)
+ 'cache' => [
+ 'cleared' => 'Alimtalk template content cache cleared. The latest content will apply from the next dispatch.',
+ ],
+
+ // Connection check (TokenCheckController response)
+ 'token_check' => [
+ 'success' => 'Authentication verified successfully. The ID and password are correct.',
+ 'failed' => 'Authentication check failed. See the detailed reason.',
+ ],
+];
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php
new file mode 100644
index 00000000..7ebca552
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/result_codes.php
@@ -0,0 +1,76 @@
+ 'Success',
+ '2000' => 'Message is invalid',
+ '3001' => 'Invalid authentication (Basic)',
+ '3002' => 'Invalid token (expired/revoked)',
+ '3003' => 'Invalid IP',
+ '3004' => 'Account is invalid',
+ '3005' => 'Invalid authentication (Bearer)',
+ '3006' => 'Account does not exist',
+ '3007' => 'Invalid account password',
+ '3009' => 'Account suspended',
+ '3010' => 'IP not in allowlist',
+ '3011' => 'Unknown error (Bizppurio)',
+ '3013' => 'Message not completed',
+ '5002' => 'Too many requests',
+ '5003' => 'Temporary delivery error',
+ '5004' => 'Temporary delivery error',
+ '5005' => 'Temporary delivery error',
+ '9000' => 'Temporary system error',
+ '9070' => 'Insufficient balance (SMS)',
+ '9071' => 'Postpaid limit exceeded',
+
+ // ── SMS report ───────────────────────────────────────
+ '4100' => 'Success',
+ '4400' => 'Out of service area',
+ '4401' => 'Power off',
+ '4402' => 'Storage full',
+ '4410' => 'Invalid number',
+ '4414' => 'Disconnected/suspended number',
+ '4420' => 'Other device error',
+ '4430' => 'Spam',
+ '4431' => 'Delivery-restricted opt-out (spam)',
+ '4443' => 'Spam blocked',
+
+ // ── LMS report ───────────────────────────────────────
+ '6600' => 'Success',
+ '6603' => 'Out of service area',
+ '6604' => 'Power off',
+ '6606' => 'Invalid number',
+ '6621' => 'Message length exceeded',
+ '6641' => 'Spam blocked',
+
+ // ── Alimtalk report ──────────────────────────────────
+ '7000' => 'Success',
+ '7103' => 'Invalid sender profile key',
+ '7106' => 'Deleted sender key',
+ '7107' => 'Blocked sender key',
+ '7204' => 'Message content does not match template',
+ '7206' => 'Serial number format mismatch',
+ '7306' => 'Kakao system error',
+ '7307' => 'Processing delayed',
+ '7308' => 'Phone number error',
+ '7320' => 'Receiver blocked',
+ '7325' => 'Variable length exceeded',
+ '7421' => 'Timeout',
+ '7436' => 'Insufficient wallet balance (Alimtalk)',
+ '7437' => 'Message request failed',
+ '7523' => '080 opt-out (spam)',
+];
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php
new file mode 100644
index 00000000..6619eb3b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php
@@ -0,0 +1,9 @@
+ [],
+ 'description' => [],
+];
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php
new file mode 100644
index 00000000..b103ad3a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php
@@ -0,0 +1,113 @@
+ [
+ 'sms' => 'SMS',
+ 'lms' => 'LMS',
+ 'alimtalk' => '알림톡',
+ ],
+
+ // 채널 그룹 라벨 (SMS/LMS 를 "문자" 로 묶음 — 잔액부족 알림 등 채널군 표기용)
+ 'channel_group' => [
+ 'text' => '문자',
+ 'alimtalk' => '알림톡',
+ ],
+
+ // 발송 상태 (DispatchStatus enum)
+ 'status' => [
+ 'pending' => '대기',
+ 'sent' => '발송중',
+ 'success' => '성공',
+ 'failed' => '실패',
+ ],
+
+ // 발송 출처 (DispatchSource enum)
+ 'source' => [
+ 'auto' => '자동',
+ 'manual' => '수동',
+ 'bulk' => '대량',
+ ],
+
+ // 결과코드 분류 (ResultCategory enum)
+ 'result_category' => [
+ 'success' => '성공',
+ 'retry' => '재시도',
+ 'permanent_failure' => '영구 실패',
+ 'balance_low' => '잔액 부족',
+ ],
+
+ // 알림 채널 메타 (core.notification.filter_available_channels)
+ 'channels' => [
+ 'source_label' => '비즈뿌리오',
+ 'sms' => [
+ 'name' => 'SMS/LMS 문자',
+ 'description' => '비즈뿌리오를 통해 문자(SMS/LMS)로 알림을 발송합니다.',
+ ],
+ 'alimtalk' => [
+ 'name' => '카카오 알림톡',
+ 'description' => '비즈뿌리오를 통해 카카오 알림톡으로 알림을 발송합니다.',
+ ],
+ ],
+
+ // 채널 준비 상태 사유 (core.notification.channel_readiness)
+ 'readiness' => [
+ 'sms_credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 설정하세요.',
+ 'sms_sender_number_missing' => '발신번호를 설정하세요.',
+ 'alimtalk_api_key_missing' => '카카오 관리 API 키를 설정하세요.',
+ 'alimtalk_sender_key_missing' => '알림톡 발신프로필 키를 설정하세요.',
+ ],
+
+ // 설정 검증 — 운영(live) 환경 필수 자격증명 항목 라벨 (validation.attributes 병합용)
+ 'settings' => [
+ 'bizppurio_id_attribute' => '비즈뿌리오 아이디',
+ 'password_attribute' => '비밀번호',
+ 'sender_number_attribute' => '발신번호',
+ ],
+
+ // webhook(URL PUSH) 리포트 수신
+ 'webhook' => [
+ 'received' => '리포트를 수신했습니다.',
+ ],
+
+ // 발송 엔진 오류 (API 클라이언트·토큰·발송 Job)
+ 'error' => [
+ 'credentials_missing' => '비즈뿌리오 아이디와 비밀번호를 먼저 설정하세요.',
+ 'token_issue_failed' => '비즈뿌리오 인증 토큰 발급에 실패했습니다.',
+ 'token_issue_failed_with_reason' => '비즈뿌리오 인증 토큰 발급에 실패했습니다. (:reason)',
+ 'send_failed' => '메시지 발송 요청에 실패했습니다.',
+ 'send_retryable' => '메시지 발송이 일시적으로 실패했습니다. (코드: :code)',
+ 'invalid_response' => '비즈뿌리오 응답을 해석할 수 없습니다.',
+ 'connection_failed' => '비즈뿌리오 서버에 연결할 수 없습니다. 잠시 후 다시 시도해주세요.',
+ 'kakao_credentials_missing' => '카카오 관리 API 사용을 위해 아이디와 API 키를 먼저 설정하세요.',
+ 'kakao_request_failed' => '카카오 관리 API 요청에 실패했습니다.',
+ 'sender_key_missing' => '알림톡 발신프로필 키를 먼저 설정하세요.',
+ 'template_not_sendable' => '발송 가능(승인) 상태가 아닌 템플릿입니다. (코드: :code)',
+ ],
+
+ // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨)
+ 'send_skipped' => [
+ 'alimtalk_binding_missing' => '알림톡 템플릿이 연결되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)',
+ 'alimtalk_kakao_content_unavailable' => '카카오 승인 템플릿 내용을 조회하지 못해 발송을 건너뛰었습니다. (알림 유형: :type)',
+ 'sms_template_missing' => 'SMS 템플릿이 없어 발송을 건너뛰었습니다. (알림 유형: :type)',
+ 'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)',
+ 'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)',
+ ],
+
+ // 알림↔알림톡 템플릿 연동 (NotificationBindingController 응답)
+ 'binding' => [
+ 'saved' => '알림톡 연동을 저장했습니다.',
+ 'removed' => '알림톡 연동을 해제했습니다.',
+ ],
+
+ // 발송용 템플릿 내용 캐시 (AlimtalkTemplateController::clearCache 응답)
+ 'cache' => [
+ 'cleared' => '알림톡 템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.',
+ ],
+
+ // 연결 확인 (TokenCheckController 응답)
+ 'token_check' => [
+ 'success' => '인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.',
+ 'failed' => '인증 확인에 실패했습니다. 상세 사유를 확인해 주세요.',
+ ],
+];
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php
new file mode 100644
index 00000000..cfd19c83
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/result_codes.php
@@ -0,0 +1,79 @@
+ '성공',
+ '2000' => '메시지가 유효하지 않음',
+ '3001' => '인증 정보 무효(Basic)',
+ '3002' => '토큰 무효(만료·폐기)',
+ '3003' => 'IP 무효',
+ '3004' => '계정이 유효하지 않음',
+ '3005' => '인증 정보 무효(Bearer)',
+ '3006' => '계정이 존재하지 않음',
+ '3007' => '계정 암호 무효',
+ '3009' => '계정 중지 상태',
+ '3010' => '접속 허용 IP 불일치',
+ '3011' => '알 수 없는 오류(비즈뿌리오)',
+ '3013' => '완료 처리되지 않은 메시지',
+ '5002' => '요청 과다',
+ '5003' => '일시적 전송 오류',
+ '5004' => '일시적 전송 오류',
+ '5005' => '일시적 전송 오류',
+ '9000' => '일시적 시스템 오류',
+ '9070' => '잔액 부족(문자)',
+ '9071' => '후불 한도 초과',
+
+ // ── SMS 리포트 ────────────────────────────────────────
+ '4100' => '성공',
+ '4400' => '음영 지역',
+ '4401' => '전원 꺼짐',
+ '4402' => '저장 매체 초과',
+ '4410' => '잘못된 번호',
+ '4414' => '결번·정지',
+ '4420' => '기타 단말 오류',
+ '4430' => '스팸',
+ '4431' => '발송 제한 수신거부(스팸)',
+ '4443' => '스팸 차단',
+
+ // ── LMS 리포트 ────────────────────────────────────────
+ '6600' => '성공',
+ '6603' => '음영 지역',
+ '6604' => '전원 꺼짐',
+ '6606' => '잘못된 번호',
+ '6621' => '메시지 길이 초과',
+ '6641' => '스팸 차단',
+
+ // ── 알림톡 리포트 ─────────────────────────────────────
+ '7000' => '성공',
+ '7103' => '발신 프로필 키 무효',
+ '7106' => '삭제된 발신 키',
+ '7107' => '차단된 발신 키',
+ '7204' => '메시지 내용이 템플릿과 불일치',
+ '7206' => '시리얼넘버 형식 불일치',
+ '7306' => '카카오 시스템 오류',
+ '7307' => '처리 지연',
+ '7308' => '전화번호 오류',
+ '7320' => '수신 차단',
+ '7325' => '변수 길이 초과',
+ '7421' => '타임아웃',
+ '7436' => '지갑 잔액 부족(알림톡)',
+ '7437' => '메시지 요청 실패',
+ '7523' => '080 수신거부(스팸)',
+];
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/package.json b/plugins/_bundled/sirsoft-message_bizppurio/package.json
new file mode 100644
index 00000000..25c55585
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@plugins/sirsoft-message_bizppurio",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "vitest",
+ "test:run": "vitest run"
+ },
+ "devDependencies": {
+ "@types/node": "^22.0.0",
+ "jsdom": "^25.0.0",
+ "typescript": "^5.6.0",
+ "vite": "^5.4.14",
+ "vitest": "^2.1.0"
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.json b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json
new file mode 100644
index 00000000..1e163be5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.json
@@ -0,0 +1,32 @@
+{
+ "identifier": "sirsoft-message_bizppurio",
+ "vendor": "sirsoft",
+ "name": {
+ "ko": "비즈뿌리오 메시지 발송",
+ "en": "Bizppurio Messaging"
+ },
+ "version": "1.0.0",
+ "description": {
+ "ko": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다. 코어 알림 시스템 채널로 문자·알림톡을 발송하고 발송 결과를 webhook 으로 수신합니다.",
+ "en": "Bizppurio SMS/LMS and KakaoTalk alimtalk plugin. Sends messages through core notification channels and receives delivery results via webhook."
+ },
+ "license": "MIT",
+ "g7_version": ">=7.0.6",
+ "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",
+ "assets": {
+ "js": {
+ "entry": "resources/js/index.ts",
+ "output": "dist/js/plugin.iife.js"
+ },
+ "handlers": true
+ },
+ "loading": {
+ "strategy": "global",
+ "priority": 100
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/plugin.php b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php
new file mode 100644
index 00000000..b5048d2b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php
@@ -0,0 +1,481 @@
+ 'Sirsoft',
+ 'license' => 'MIT',
+ 'homepage' => 'https://sir.kr',
+ 'keywords' => ['bizppurio', 'sms', 'lms', 'alimtalk', 'kakao', 'messaging', 'notification'],
+ ];
+ }
+
+ /**
+ * 플러그인 활성화 — 기존 회원 알림에 sms·alimtalk template 을 즉시 증강.
+ *
+ * SeedChannelTemplatesListener 는 코어/모듈이 알림 정의를 *시딩할 때* 필터 훅으로 증강한다.
+ * 그러나 플러그인 활성화 시점에는 코어/모듈 정의가 이미 시딩돼 있어(우리 채널 없이) 다음
+ * 재시딩까지 alimtalk template 이 생기지 않는다. 따라서 활성화 시 코어·활성 모듈의 알림
+ * 정의를 재시드해, 이제 활성화된 우리 필터를 통과시켜 기존 정의에도 채널을 즉시 반영한다.
+ *
+ * 재시드는 모두 user_overrides 보존 upsert(멱등)이며 코어·모듈 파일을 수정하지 않는다.
+ * 실패는 로그만 남기고 활성화 자체는 막지 않는다(발송·연동은 채널 등록만으로도 동작하며,
+ * template 은 다음 정상 재시딩에도 수렴).
+ *
+ * 리스너 선등록: PluginManager::activatePlugin() 은 DB status 를 active 로 바꾸기
+ * '전에' 이 activate() 를 호출한다. 그런데 registerPluginHookListeners() 의 active 가드는
+ * status=active 인 플러그인의 리스너만 등록하므로, 이 시점엔 SeedChannelTemplatesListener 가
+ * 아직 등록되지 않았다. 그대로 재시딩하면 시딩 필터에 우리 리스너가 편승하지 못해
+ * sms·alimtalk 채널 template 이 붙지 않는다(실서버 회귀). 따라서 재시딩 전에 이 리스너를
+ * 명시적으로 선등록한다. register() 는 동일 process 내 중복 등록을 막으므로 멱등하다.
+ *
+ * @return bool 활성화 성공 여부
+ */
+ public function activate(): bool
+ {
+ try {
+ // 재시딩이 편승할 시딩 필터 리스너를 status=active 전환 이전에 명시적으로 등록한다.
+ HookListenerRegistrar::register(SeedChannelTemplatesListener::class, $this->getIdentifier());
+
+ app(NotificationDefinitionSeeder::class)->run();
+ app(ModuleManager::class)->resyncAllActiveDeclarativeArtifacts();
+ } catch (\Throwable $e) {
+ Log::warning('[sirsoft-message_bizppurio] 알림 채널 template 증강 재시드 실패', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ return true;
+ }
+
+ /**
+ * 플러그인 비활성화 — 이 플러그인 소속 관리자 메뉴 잔재 정리.
+ *
+ * 이 플러그인은 관리자 메뉴를 만들지 않는다(화면 배치 결정 2026-07-14 — 진입은 코어
+ * 소유 설정 페이지 하나로 통일). 다만 이전 버전(getAdminMenus 사용 시기)에 생성된
+ * 메뉴 row 가 DB 에 남아 있을 수 있어, 비활성화 시 currentSlugs=[] 로 cleanupStaleMenus
+ * 를 호출해 잔재를 청소한다. 재활성화 시 아무 메뉴도 만들지 않으므로 정상 무메뉴 상태로
+ * 수렴한다(멱등). 자식 메뉴 + role_menus 피벗은 helper 가 cascade 처리.
+ *
+ * SeedChannelTemplatesListener 의 HookListenerRegistrar 등록 이력 캐시를 함께 지운다 —
+ * activate() 가 register() 로 등록한 뒤(process-wide idempotency 캐시, source::class 키),
+ * deactivate/uninstall 후 재활성화해도 이 캐시가 "이미 등록됨"으로 남아있으면 재등록이
+ * 조용히 skip 되어 재시딩 시 sms·alimtalk 채널이 다시 붙지 않는다(회귀). 코어
+ * HookListenerRegistrar 에는 특정 키 1건만 지우는 API 가 없고(clear() 는 전체 초기화라
+ * 다른 확장의 등록 상태까지 날아감), 코어 수정 없이 이 플러그인 내부에서만 해결하기
+ * 위해 리플렉션으로 private static $registered 캐시에서 이 키만 직접 제거한다.
+ *
+ * @return bool 비활성화 성공 여부
+ */
+ public function deactivate(): bool
+ {
+ app(ExtensionMenuSyncHelper::class)->cleanupStaleMenus(
+ ExtensionOwnerType::Plugin,
+ $this->getIdentifier(),
+ currentSlugs: [],
+ );
+
+ $this->forgetSeedChannelTemplatesListenerRegistration();
+
+ return true;
+ }
+
+ /**
+ * HookListenerRegistrar::$registered 캐시에서 SeedChannelTemplatesListener 등록 이력만 제거합니다.
+ *
+ * 코어 HookListenerRegistrar 는 개별 키 삭제 API 를 제공하지 않으므로(clear() 는 전체
+ * 초기화), 코어를 수정하지 않고 이 캐시를 조작하기 위해 리플렉션을 사용한다. 실패해도
+ * (리플렉션 예외 등) 치명적이지 않으므로 조용히 무시한다 — 최악의 경우 이번 재활성화만
+ * 채널 재시딩이 안 붙고, 운영자가 다시 활성화하면 정상화된다.
+ */
+ private function forgetSeedChannelTemplatesListenerRegistration(): void
+ {
+ try {
+ $ref = new \ReflectionClass(HookListenerRegistrar::class);
+ $prop = $ref->getProperty('registered');
+ $registered = $prop->getValue();
+ $key = $this->getIdentifier().'::'.SeedChannelTemplatesListener::class;
+ unset($registered[$key]);
+ $prop->setValue(null, $registered);
+ } catch (\Throwable $e) {
+ Log::warning('[sirsoft-message_bizppurio] SeedChannelTemplatesListener 등록 캐시 초기화 실패', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * 플러그인 제거 — 메뉴 잔재 안전망(정상 흐름은 deactivate 가 먼저 처리) + sms·alimtalk 채널 잔재 정리.
+ *
+ * SeedChannelTemplatesListener 가 코어/게시판/이커머스 소유 알림 정의에 필터 훅으로 끼워넣은
+ * sms·alimtalk template 은, 그 정의가 우리 소유가 아니므로 PluginManager 의 범용 알림 정의
+ * 정리(cleanupStaleDefinitions('plugin', ...))에 걸리지 않는다. 코어는 이 채널의 존재나
+ * 소유자를 몰라야 하므로, 코어를 수정하지 않고 우리가 직접 우리 채널 상수 기준으로 정리한다.
+ *
+ * "데이터도 함께 삭제" 옵션과 무관하게 항상 정리한다 — sms·alimtalk 은 이 플러그인 없이는
+ * 발송이 불가능한 죽은 설정이라 보존할 가치가 없고, 재설치 시 activate() 가 재시딩하며
+ * 자동으로 복원된다.
+ *
+ * @return bool 제거 성공 여부
+ */
+ public function uninstall(): bool
+ {
+ $this->deactivate();
+ $this->cleanupChannelContributions();
+
+ return true;
+ }
+
+ /**
+ * sms·alimtalk 채널 template 및 definitions.channels 배열에서 우리 채널 잔재를 정리합니다.
+ *
+ * 삭제 대상 채널은 우리가 이미 아는 채널 상수(RegisterNotificationChannelsListener::CHANNEL_IDS)
+ * 이므로, 각 정의가 원래 어떤 채널로 구성돼 있었는지 사전 지식 없이 "현재 DB 채널 목록 −
+ * 우리 채널" 만으로 남길 목록을 계산할 수 있다. template 삭제는 코어 공개 메서드
+ * NotificationSyncHelper::cleanupStaleTemplates() 를 그대로 사용해 로깅·모델 이벤트 등
+ * 코어 도메인 규칙을 그대로 따른다.
+ */
+ private function cleanupChannelContributions(): void
+ {
+ $myChannels = RegisterNotificationChannelsListener::CHANNEL_IDS;
+ $helper = app(NotificationSyncHelper::class);
+
+ $definitionIds = DB::table('notification_templates')
+ ->whereIn('channel', $myChannels)
+ ->distinct()
+ ->pluck('definition_id');
+
+ foreach ($definitionIds as $definitionId) {
+ $current = DB::table('notification_templates')
+ ->where('definition_id', $definitionId)
+ ->pluck('channel')
+ ->all();
+ $keep = array_values(array_diff($current, $myChannels));
+
+ $helper->cleanupStaleTemplates($definitionId, $keep);
+
+ $definition = NotificationDefinition::find($definitionId);
+ if ($definition) {
+ $definition->channels = $keep;
+ $definition->save();
+ }
+ }
+ }
+
+ /**
+ * 플러그인 권한 목록 반환 (계층 구조)
+ *
+ * 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 [
+ 'is_test_mode' => [
+ 'type' => 'boolean',
+ 'default' => true,
+ 'label' => ['ko' => '검수 모드', 'en' => 'Test Mode'],
+ 'hint' => [
+ 'ko' => '검수 모드를 끄면 운영 환경으로 발송됩니다. 발송 API 도메인이 환경에 따라 분기됩니다.',
+ 'en' => 'Turn off test mode to send in the 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,
+ ],
+ 'template_cache_minutes' => [
+ 'type' => 'number',
+ 'default' => 60,
+ 'label' => ['ko' => '알림톡 내용 캐시 시간(분)', 'en' => 'Alimtalk content cache (minutes)'],
+ 'hint' => [
+ 'ko' => '카카오 알림톡 템플릿 내용을 이 시간 동안 기억해 재사용합니다(기본 60분). 이 시간이 지나면 다음 발송 때 최신 내용을 다시 가져옵니다. 0으로 두면 매번 최신 내용을 가져옵니다 — 발송이 많으면 조회 제한에 걸릴 수 있어 권장하지 않습니다. 카카오에서 템플릿을 방금 수정했다면 아래 [캐시 초기화]로 즉시 반영할 수 있습니다.',
+ 'en' => 'Reuses Kakao alimtalk template content for this period (default 60 minutes). After it expires, the latest content is fetched on the next dispatch. Set to 0 to always fetch the latest — not recommended for high volume as it may hit rate limits. If you just edited a template in Kakao, use [Clear cache] below to apply it immediately.',
+ ],
+ 'required' => false,
+ ],
+ ];
+ }
+
+ /**
+ * 플러그인 설정 기본값 반환 (하위 호환)
+ *
+ * 신규 설치 기본값은 config/settings/defaults.json 의 defaults 섹션이 1순위이며,
+ * 본 메서드는 하위 호환 경로로 동일 값을 반환한다.
+ *
+ * @return array 기본 설정값
+ */
+ public function getConfigValues(): array
+ {
+ return [
+ 'is_test_mode' => true,
+ 'bizppurio_id' => '',
+ 'password' => '',
+ 'api_key' => '',
+ 'sender_number' => '',
+ 'sender_key' => '',
+ 'template_cache_minutes' => 60,
+ ];
+ }
+
+ /**
+ * 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다.
+ *
+ * Phase 1 범위: 비즈뿌리오 지갑 잔액부족 시 관리자에게 보내는 자체 알림 1건.
+ * (webhook 결과코드 9070/7436 감지 시 Phase 4 에서 이 알림을 발화)
+ *
+ * ※ 3영역(코어/게시판/이커머스) 알림톡 채널 기본 body 시드는 알림톡 채널 등록(Phase 3)·
+ * 탭 연동(Phase 6)과 강결합이므로 Phase 6 으로 이관한다. 그때 본 메서드에 정의를 추가한다.
+ *
+ * @return array>
+ */
+ 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}).',
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+
+ /**
+ * 훅 리스너 목록 반환
+ *
+ * - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3)
+ * - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅)
+ * - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3)
+ * - LinkNotificationLogListener: 코어 알림 로그↔dispatch 연결(A-2 — 발송 이력 결과 주입 연결고리)
+ * - ValidateBizppurioSettingsListener: 환경설정 검증
+ * - InvalidateTokenOnSettingsSaveListener: 설정 저장 시 인증 토큰 캐시 무효화
+ *
+ * @return array
+ */
+ public function getHookListeners(): array
+ {
+ return [
+ RegisterNotificationChannelsListener::class,
+ SeedChannelTemplatesListener::class,
+ GuestPhoneExtractListener::class,
+ BalanceLowNotificationDataListener::class,
+ LinkNotificationLogListener::class,
+ ValidateBizppurioSettingsListener::class,
+ InvalidateTokenOnSettingsSaveListener::class,
+ ];
+ }
+
+ /**
+ * 확장 미들웨어 선언 (self-gate)
+ *
+ * webhook(URL PUSH) 리포트 수신 엔드포인트에만 IP 화이트리스트를 부착한다.
+ * 라우트 파일에서 직접 부착하지 않고 코어 게이트(ExtensionMiddlewareGate)가
+ * 요청 시점에 라우트 이름을 대조해 매칭될 때만 실행한다.
+ *
+ * @return array>
+ */
+ public function getMiddleware(): array
+ {
+ return [
+ [
+ 'class' => BizppurioWebhookIpWhitelist::class,
+ 'groups' => ['api'],
+ 'targets' => [
+ 'api.plugins.sirsoft-message_bizppurio.webhook',
+ ],
+ ],
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json
new file mode 100644
index 00000000..fd8e707c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_log_result.json
@@ -0,0 +1,210 @@
+{
+ "target_layout": "admin_notification_log_list",
+ "comment": "코어 알림 발송 이력 화면에 비즈뿌리오 발송 결과 컬럼을 얹는다(A-2). 코어 화면·코어 앱·코어 테이블은 전혀 건드리지 않는다 — 연결 표식은 우리 dispatch(bizppurio_dispatches.notification_log_id)에만 있고, 현재 페이지의 코어 로그 id 배열로 결과를 배치 조회(dispatchResults)해 row.id 로 매칭한다. 매칭 안 되는 행(메일·DB 등 비-비즈뿌리오)은 빈 셀. 컬럼은 코어 datagrid columns 배열 끝에 _append 로 1개만 추가(전체 컬럼 재정의 안 함).",
+ "data_sources": [
+ {
+ "id": "dispatchResults",
+ "label_key": "$t:sirsoft-message_bizppurio.editor.data_source.dispatch_results",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent",
+ "method": "GET",
+ "auto_fetch": true,
+ "auth_required": true,
+ "loading_strategy": "progressive",
+ "fallback": {
+ "data": {
+ "results": {}
+ }
+ }
+ }
+ ],
+ "injections": [
+ {
+ "target_id": "notification_log_datagrid",
+ "position": "inject_props",
+ "comment": "코어 datagrid columns 끝에 비즈뿌리오 결과 컬럼 1개만 추가. columns 는 정적 배열이라 _append 적용 가능.",
+ "props": {
+ "expandChildren": {
+ "_append": [
+ {
+ "comment": "행 토글(펼침) 안에도 비즈뿌리오 결과를 넣는다. 비즈뿌리오 발송 행(결과 매칭됨)일 때만 노출 — 메일·사이트내알림 등 매칭 안 되는 행에선 아무것도 안 보인다.",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}",
+ "props": {
+ "className": "p-4 bg-gray-50 dark:bg-gray-900 border-t border-gray-200 dark:border-gray-700 space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex items-center gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-comment-dots",
+ "size": "sm",
+ "className": "text-teal-600 dark:text-teal-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm font-medium text-gray-800 dark:text-gray-100"
+ },
+ "text": "$t:sirsoft-message_bizppurio.dispatch_result.detail_title"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex flex-wrap items-center gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}"
+ },
+ "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}",
+ "props": {
+ "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50"
+ },
+ "text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}",
+ "props": {
+ "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100"
+ },
+ "text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}"
+ }
+ ]
+ },
+ {
+ "comment": "알림톡 실제 발송 내용 — 코어 '본문'(notification_logs.body)은 SMS 대체발송용 코어 템플릿 값이라 실제 카카오 발송 내용(승인 템플릿 전체)과 다르다. row.channel==='alimtalk'이고 실제 발송 내용이 있을 때만 별도 노출해 혼동을 방지한다.",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{row.channel === 'alimtalk' && !!(dispatchResults?.data?.results ?? {})[row.id]?.content}}",
+ "props": {
+ "className": "pt-2 border-t border-gray-200 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "form-label"
+ },
+ "text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-line admin-card"
+ },
+ "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.content}}"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.dispatch_result.sent_content_hint"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "columns": {
+ "_append": [
+ {
+ "field": "bizppurio_result",
+ "header": "$t:sirsoft-message_bizppurio.dispatch_result.column_header",
+ "width": "200px",
+ "sortable": false,
+ "required": false,
+ "comment": "컬럼(헤더 포함) 자체를 탭별로 노출 제어. 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로(셀과 달리) query 접근 가능. 메일·사이트내알림(mail/database) 탭이면 hidden=true 로 컬럼 숨김. 전체·sms·lms·알림톡 탭에선 표시.",
+ "hidden": "{{['mail','database'].includes(query.channel ?? '')}}",
+ "cellChildren": [
+ {
+ "comment": "비즈뿌리오 발송 행(row.channel 이 sms/lms/alimtalk)에서만 결과 표시. 메일·사이트내알림(mail/database) 행은 셀을 비운다. 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없으므로(코어 DataGrid renderCellChildren 계약) 탭이 아니라 row.channel 로 판별한다.",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{['sms','lms','alimtalk'].includes(row.channel)}}",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!(dispatchResults?.data?.results ?? {})[row.id]}}",
+ "props": {
+ "className": "text-gray-300 dark:text-gray-600"
+ },
+ "text": "-"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]}}",
+ "props": {
+ "className": "flex flex-col gap-1 items-start"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'success' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100' : (dispatchResults?.data?.results ?? {})[row.id]?.status === 'failed' ? 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-700 dark:text-red-100' : 'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-50'}}"
+ },
+ "text": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_test_mode === true ? $t('sirsoft-message_bizppurio.dispatch_result.inspection_label') : (dispatchResults?.data?.results ?? {})[row.id]?.result_label ?? (dispatchResults?.data?.results ?? {})[row.id]?.status_label ?? '-'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{(dispatchResults?.data?.results ?? {})[row.id]?.is_low_balance === true}}",
+ "props": {
+ "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-600 dark:text-orange-50"
+ },
+ "text": "$t:sirsoft-message_bizppurio.dispatch_result.low_balance"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!!(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}",
+ "props": {
+ "className": "inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-800 dark:bg-sky-700 dark:text-sky-100"
+ },
+ "text": "$t:sirsoft-message_bizppurio.dispatch_result.fallback|status={{(dispatchResults?.data?.results ?? {})[row.id]?.fallback_status}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "priority": 330
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json
new file mode 100644
index 00000000..9d4c2d1e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json
@@ -0,0 +1,139 @@
+{
+ "extension_point": "notification_definition_row_footer",
+ "comment": "알림 설정 알림톡 탭의 각 알림 행 하단(코어 notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼을 채운다(계획서 §6-2, Phase 6 재설계). channel==='alimtalk'일 때만 노출. extensionPointProps.definition 으로 이 행의 알림(def)을 받고, 연결 정보는 bizppurioBindings(overlay notification_tab_core.json 이 등록·조회) 에서 def.type 으로 읽는다. [연결/변경] → 우리 연결 모달(modal_bizppurio_binding, overlay 가 등록)을 연다. 코어 목록·행은 건드리지 않으며, 플러그인 미설치 시 슬롯은 빈 자리로 남는다.",
+ "components": [
+ {
+ "id": "bizppurio_row_binding",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{extensionPointProps.activeChannel === 'alimtalk'}}",
+ "props": {
+ "className": "flex-between gap-2 mt-3 pt-3 border-t border-gray-100 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2 min-w-0"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-link",
+ "size": "sm",
+ "className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
+ }
+ },
+ {
+ "comment": "연결됨: 템플릿명(또는 코드)",
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}",
+ "props": {
+ "className": "text-sm text-gray-700 dark:text-gray-200 truncate"
+ },
+ "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name || bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code}}"
+ },
+ {
+ "comment": "소실 경고 — 연결됨이지만 연결한 카카오 템플릿이 삭제·차단·미승인되어 발송 불가(is_unavailable). 승인 목록 대조가 가능했고(=is_unavailable 필드 존재) true 일 때만 표시. 카카오 조회 실패 시 서비스가 필드를 부여하지 않아 배지도 뜨지 않는다(오탐 방지).",
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.is_unavailable === true}}",
+ "props": {
+ "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100 flex-shrink-0"
+ },
+ "children": [
+ { "type": "basic", "name": "Icon", "props": { "name": "fas fa-triangle-exclamation", "size": "sm" } },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.unavailable" }
+ ]
+ },
+ {
+ "comment": "⑧ SMS 대체발송 배지 — 연결됨일 때만, 켜짐/꺼짐을 색으로 구분해 둘 다 표시(다크에서 묽지 않게 solid 색)",
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled}}",
+ "props": {
+ "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-teal-100 dark:bg-teal-900 text-teal-700 dark:text-teal-200 flex-shrink-0"
+ },
+ "children": [
+ { "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_on" }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code && !(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}",
+ "props": {
+ "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0"
+ },
+ "children": [
+ { "type": "basic", "name": "Icon", "props": { "name": "comment-sms", "size": "sm" } },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.binding.fallback_off" }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!(bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code)}}",
+ "props": {
+ "className": "text-sm text-gray-400 dark:text-gray-500"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.unbound"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-3 py-1.5 rounded-md border border-teal-300 dark:border-teal-600 text-teal-700 dark:text-teal-300 hover:bg-teal-50 dark:hover:bg-teal-900/30 whitespace-nowrap flex-shrink-0"
+ },
+ "text": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ? '$t:sirsoft-message_bizppurio.binding.btn_change' : '$t:sirsoft-message_bizppurio.binding.btn_connect'}}",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "comment": "모달 상태 seed — 대상 알림 type·이름·변수 + 기존 연결값 프리필",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal": {
+ "notification_type": "{{extensionPointProps.definition?.type}}",
+ "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
+ "variables": "{{extensionPointProps.definition?.variables ?? []}}",
+ "template_code": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_code ?? ''}}",
+ "template_name": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.template_name ?? ''}}",
+ "fallback_sms": "{{bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]?.fallback_sms_enabled ?? false}}",
+ "isSaving": false
+ }
+ }
+ },
+ {
+ "handler": "openModal",
+ "target": "modal_bizppurio_binding"
+ },
+ {
+ "comment": "승인 템플릿 드롭다운 옵션 조회(모달 열 때만 → 전 탭 에러 없음). 자격증명 미설정 시 422 는 fallback 으로 조용히 처리되며, openModal 뒤에 두어 조회 실패가 모달 표시를 막지 않게 한다.",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioApprovedTemplates"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "priority": 320
+}
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json
new file mode 100644
index 00000000..a1c53c27
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json
@@ -0,0 +1,483 @@
+{
+ "target_layout": "admin_board_settings",
+ "comment": "게시판 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 게시판 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 게시판 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).",
+ "data_sources": [
+ {
+ "id": "bizppurioBindings",
+ "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "fallback": {
+ "data": {
+ "bindings": {}
+ }
+ }
+ },
+ {
+ "id": "bizppurioApprovedTemplates",
+ "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "templates": [],
+ "load_failed": true
+ }
+ }
+ }
+ ],
+ "injections": [
+ {
+ "target_id": "board_notif_channel_content",
+ "position": "prepend_child",
+ "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
+ "components": [
+ {
+ "id": "bizppurio_board_status_banner",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true)}}",
+ "props": {
+ "className": "mt-4 mb-3 space-y-2"
+ },
+ "children": [
+ {
+ "id": "bizppurio_board_banner_not_ready",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false}}",
+ "props": {
+ "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-exclamation",
+ "size": "sm",
+ "className": "text-red-600 dark:text-red-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-red-800 dark:text-red-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.not_ready"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.setup_action",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/sirsoft-message_bizppurio/settings"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_board_banner_test_mode",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true}}",
+ "props": {
+ "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-flask",
+ "size": "sm",
+ "className": "text-amber-600 dark:text-amber-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-amber-800 dark:text-amber-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.test_mode"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_board_alimtalk_guide",
+ "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(query.channel) === 'alimtalk'}}",
+ "props": {
+ "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-info",
+ "size": "sm",
+ "className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-blue-800 dark:text-blue-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.list_guide"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "modals": [
+ {
+ "id": "modal_bizppurio_binding",
+ "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}",
+ "size": "md",
+ "closeOnOverlayClick": false
+ },
+ "children": [
+ {
+ "id": "bizppurio_board_binding_modal_body",
+ "type": "basic",
+ "name": "Div",
+ "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}",
+ "props": {
+ "className": "space-y-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-info",
+ "size": "sm",
+ "className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-teal-700 dark:text-teal-300"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.section_hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary block text-xs font-medium mb-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.connected_template"
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
+ "className": "w-full text-sm",
+ "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.template_code": "{{$event.target.value}}",
+ "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
+ "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
+ "type": "basic",
+ "name": "P",
+ "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
+ "props": {
+ "className": "text-xs text-red-600 dark:text-red-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
+ },
+ {
+ "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
+ "type": "basic",
+ "name": "P",
+ "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
+ "props": {
+ "className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2"
+ },
+ "children": [
+ {
+ "type": "composite",
+ "name": "Toggle",
+ "props": {
+ "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
+ "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-secondary text-xs"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_board_binding_fallback_hint",
+ "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
+ },
+ {
+ "id": "bizppurio_board_binding_variables",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}",
+ "props": {
+ "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex flex-wrap gap-1"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "iteration": {
+ "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}",
+ "item_var": "v"
+ },
+ "props": {
+ "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
+ },
+ "text": "{{'#{' + (v.key ?? '') + '}'}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50",
+ "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
+ },
+ "text": "$t:common.cancel",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_binding"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50",
+ "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
+ },
+ "text": "$t:common.save",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": true
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
+ "params": {
+ "method": "POST",
+ "body": {
+ "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}",
+ "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
+ "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}",
+ "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioBindings"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.binding.saved",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_binding"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.binding.save_error",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "init_actions": [
+ {
+ "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioBindings"
+ }
+ }
+ ],
+ "priority": 320
+}
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json
new file mode 100644
index 00000000..56e5356c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json
@@ -0,0 +1,483 @@
+{
+ "target_layout": "admin_settings",
+ "comment": "알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(계획서 §6-2, Phase 6 재설계). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯)에 연결 상태 줄 + [연결/변경] 버튼(channel==='alimtalk'일 때만) ③ [연결] 클릭 시 우리 연결 모달(modals 병합)에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API. 편집 모달(본문 편집)과 완전히 분리되어 헷갈리지 않는다.",
+ "data_sources": [
+ {
+ "id": "bizppurioBindings",
+ "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "fallback": {
+ "data": {
+ "bindings": {}
+ }
+ }
+ },
+ {
+ "id": "bizppurioApprovedTemplates",
+ "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단(결정 C). 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용, 토스트 재도입 금지 — 모달 열 때마다 에러 토스트가 뜨던 회귀). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다 — 정상 응답에는 이 필드가 없으므로, 드롭다운이 비었을 때 load_failed 유무로 '불러오지 못함(자격증명 확인)' vs '승인 템플릿 없음'을 분기한다.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "templates": [],
+ "load_failed": true
+ }
+ }
+ }
+ ],
+ "injections": [
+ {
+ "target_id": "notif_channel_content",
+ "position": "prepend_child",
+ "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
+ "components": [
+ {
+ "id": "bizppurio_status_banner",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true)}}",
+ "props": {
+ "className": "mt-4 mb-3 space-y-2"
+ },
+ "children": [
+ {
+ "id": "bizppurio_banner_not_ready",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false}}",
+ "props": {
+ "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-exclamation",
+ "size": "sm",
+ "className": "text-red-600 dark:text-red-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-red-800 dark:text-red-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.not_ready"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.setup_action",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/sirsoft-message_bizppurio/settings"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_banner_test_mode",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true}}",
+ "props": {
+ "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-flask",
+ "size": "sm",
+ "className": "text-amber-600 dark:text-amber-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-amber-800 dark:text-amber-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.test_mode"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_alimtalk_guide",
+ "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(query.channel) === 'alimtalk'}}",
+ "props": {
+ "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-info",
+ "size": "sm",
+ "className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-blue-800 dark:text-blue-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.list_guide"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "modals": [
+ {
+ "id": "modal_bizppurio_binding",
+ "comment": "알림톡 연결 전용 모달(우리 소유, 신규). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}",
+ "size": "md",
+ "closeOnOverlayClick": false
+ },
+ "children": [
+ {
+ "id": "bizppurio_binding_modal_body",
+ "type": "basic",
+ "name": "Div",
+ "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}",
+ "props": {
+ "className": "space-y-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-info",
+ "size": "sm",
+ "className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-teal-700 dark:text-teal-300"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.section_hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary block text-xs font-medium mb-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.connected_template"
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
+ "className": "w-full text-sm",
+ "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.template_code": "{{$event.target.value}}",
+ "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
+ "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
+ "type": "basic",
+ "name": "P",
+ "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
+ "props": {
+ "className": "text-xs text-red-600 dark:text-red-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
+ },
+ {
+ "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
+ "type": "basic",
+ "name": "P",
+ "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
+ "props": {
+ "className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2"
+ },
+ "children": [
+ {
+ "type": "composite",
+ "name": "Toggle",
+ "props": {
+ "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
+ "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-secondary text-xs"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_binding_fallback_hint",
+ "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
+ },
+ {
+ "id": "bizppurio_binding_variables",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}",
+ "props": {
+ "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex flex-wrap gap-1"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "iteration": {
+ "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}",
+ "item_var": "v"
+ },
+ "props": {
+ "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
+ },
+ "text": "{{'#{' + (v.key ?? '') + '}'}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50",
+ "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
+ },
+ "text": "$t:common.cancel",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_binding"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50",
+ "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
+ },
+ "text": "$t:common.save",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": true
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
+ "params": {
+ "method": "POST",
+ "body": {
+ "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}",
+ "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
+ "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}",
+ "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioBindings"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.binding.saved",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_binding"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.binding.save_error",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "init_actions": [
+ {
+ "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioBindings"
+ }
+ }
+ ],
+ "priority": 320
+}
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json
new file mode 100644
index 00000000..de4b21c9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json
@@ -0,0 +1,483 @@
+{
+ "target_layout": "admin_ecommerce_settings",
+ "comment": "이커머스 알림 설정 알림톡 탭에 비즈뿌리오 연동 UI 를 얹는다(notification_tab_core.json 과 동일 패턴, target_layout/target_id/모달 id 만 이커머스 전용). 코어 목록·편집 모달·저장 버튼은 건드리지 않는다(무오염). ① 탭 상단 상태 배너(문제 있을 때만) ② 각 알림 행 하단(notification_definition_row_footer 확장 슬롯, notification_row_footer.json 이 채널 무관 전역 매칭)에 연결 상태 줄 + [연결/변경] 버튼 ③ [연결] 클릭 시 이 모듈 전용 연결 모달에서 승인 템플릿 선택·SMS 대체 설정 후 [저장]. 저장은 우리 API(notification_type 은 이커머스 알림 type 값 그대로 전달, 백엔드는 확장 구분 없는 문자열 키).",
+ "data_sources": [
+ {
+ "id": "bizppurioBindings",
+ "comment": "행 연결 상태 표시 + 모달 프리필용. auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지. 알림톡 탭 진입 시 init_actions 로 조회한다.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "fallback": {
+ "data": {
+ "bindings": {}
+ }
+ }
+ },
+ {
+ "id": "bizppurioApprovedTemplates",
+ "comment": "연결 모달 드롭다운 옵션(카카오 관리 API 위임). auto_fetch:false — 자격증명 미설정 시 카카오 API 422 가 전 탭에서 발화하던 문제 차단. 연결 모달 열 때만 조회. errorHandling.default:suppress — 자격증명 미설정 시 422(카카오 API 요청 실패)를 조용히 무시한다(fallback 사용). 조회 실패와 '진짜 0건'을 화면에서 구분하기 위해 fallback.data.load_failed:true 마커를 싣는다.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings/approved-templates",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "templates": [],
+ "load_failed": true
+ }
+ }
+ }
+ ],
+ "injections": [
+ {
+ "target_id": "ecommerce_notif_channel_content",
+ "position": "prepend_child",
+ "comment": "알림톡·SMS 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드.",
+ "components": [
+ {
+ "id": "bizppurio_ecommerce_status_banner",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{['sms','alimtalk'].includes(query.channel ?? '') && ((availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false || (availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true)}}",
+ "props": {
+ "className": "mt-4 mb-3 space-y-2"
+ },
+ "children": [
+ {
+ "id": "bizppurio_ecommerce_banner_not_ready",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.readiness?.ready === false}}",
+ "props": {
+ "className": "flex-between gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-exclamation",
+ "size": "sm",
+ "className": "text-red-600 dark:text-red-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-red-800 dark:text-red-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.not_ready"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-3 py-1.5 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 whitespace-nowrap"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.setup_action",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/sirsoft-message_bizppurio/settings"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_ecommerce_banner_test_mode",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(availableChannels?.data?.channels ?? []).find(c => c.id === (query.channel))?.is_test_mode === true}}",
+ "props": {
+ "className": "flex-center gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-flask",
+ "size": "sm",
+ "className": "text-amber-600 dark:text-amber-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-amber-800 dark:text-amber-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.banner.test_mode"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_ecommerce_alimtalk_guide",
+ "comment": "알림톡 탭 상시 안내 박스 — 이 화면에서 무엇을 하는지(각 알림에 승인 템플릿을 연결). alimtalk 탭일 때만. 배너(빨강/노랑)와 구분되게 연한 파랑 정보 박스.",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(query.channel) === 'alimtalk'}}",
+ "props": {
+ "className": "flex items-center gap-2 mt-3 mb-1 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-info",
+ "size": "sm",
+ "className": "text-blue-500 dark:text-blue-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm text-blue-800 dark:text-blue-200"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.list_guide"
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "modals": [
+ {
+ "id": "modal_bizppurio_binding",
+ "comment": "알림톡 연결 전용 모달(우리 소유). 코어 편집 모달과 분리. 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 + [취소][저장]. 저장은 우리 API store. 모달 컨텍스트 분리 대응으로 값은 _global.bizppurio_binding_modal 에 담는다.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.binding.modal_title|name={{_global.bizppurio_binding_modal?.notification_name ?? ''}}",
+ "size": "md",
+ "closeOnOverlayClick": false
+ },
+ "children": [
+ {
+ "id": "bizppurio_ecommerce_binding_modal_body",
+ "type": "basic",
+ "name": "Div",
+ "blur_until_loaded": "{{_global.bizppurio_binding_modal?.isSaving}}",
+ "props": {
+ "className": "space-y-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex items-center gap-2 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-info",
+ "size": "sm",
+ "className": "text-teal-600 dark:text-teal-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-teal-700 dark:text-teal-300"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.section_hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary block text-xs font-medium mb-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.connected_template"
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "value": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
+ "className": "w-full text-sm",
+ "options": "{{[{ value: '', label: $t('sirsoft-message_bizppurio.binding.none') }, ...(bizppurioApprovedTemplates?.data?.templates ?? []).map(t => ({ value: t.template_code, label: t.template_name + ' (' + t.template_code + ')' }))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.template_code": "{{$event.target.value}}",
+ "bizppurio_binding_modal.template_name": "{{((bizppurioApprovedTemplates?.data?.templates ?? []).find(t => t.template_code === $event.target.value)?.template_name) ?? ''}}",
+ "bizppurio_binding_modal.fallback_sms": "{{$event.target.value === '' ? false : (_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "드롭다운이 비었고 조회 실패(fallback 마커)일 때 — 키 오류 등을 '0건'과 구분해 설정 확인을 안내.",
+ "type": "basic",
+ "name": "P",
+ "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && bizppurioApprovedTemplates?.data?.load_failed === true}}",
+ "props": {
+ "className": "text-xs text-red-600 dark:text-red-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.templates_load_failed"
+ },
+ {
+ "comment": "드롭다운이 비었고 조회는 정상(load_failed 없음)일 때 — 실제 승인 템플릿 0건.",
+ "type": "basic",
+ "name": "P",
+ "if": "{{(bizppurioApprovedTemplates?.data?.templates ?? []).length === 0 && !(bizppurioApprovedTemplates?.data?.load_failed)}}",
+ "props": {
+ "className": "text-xs text-amber-600 dark:text-amber-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.no_approved_templates"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2"
+ },
+ "children": [
+ {
+ "type": "composite",
+ "name": "Toggle",
+ "props": {
+ "checked": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}",
+ "disabled": "{{(_global.bizppurio_binding_modal?.template_code ?? '') === ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.fallback_sms": "{{!(_global.bizppurio_binding_modal?.fallback_sms ?? false)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-secondary text-xs"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.fallback_sms"
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_ecommerce_binding_fallback_hint",
+ "comment": "SMS 대체발송 시 무엇이 발송되는지 안내 — 이 알림의 본문이 문자로 발송됨.",
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400 pl-11 -mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.fallback_hint"
+ },
+ {
+ "id": "bizppurio_ecommerce_binding_variables",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(_global.bizppurio_binding_modal?.variables ?? []).length > 0}}",
+ "props": {
+ "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.binding.variables_hint"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex flex-wrap gap-1"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "iteration": {
+ "source": "{{_global.bizppurio_binding_modal?.variables ?? []}}",
+ "item_var": "v"
+ },
+ "props": {
+ "className": "text-xs font-mono px-1.5 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded text-gray-700 dark:text-gray-300"
+ },
+ "text": "{{'#{' + (v.key ?? '') + '}'}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50",
+ "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
+ },
+ "text": "$t:common.cancel",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_binding"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md bg-teal-600 text-white hover:bg-teal-700 dark:bg-teal-500 dark:hover:bg-teal-600 disabled:opacity-50",
+ "disabled": "{{_global.bizppurio_binding_modal?.isSaving ?? false}}"
+ },
+ "text": "$t:common.save",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": true
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings",
+ "params": {
+ "method": "POST",
+ "body": {
+ "notification_type": "{{_global.bizppurio_binding_modal?.notification_type}}",
+ "template_code": "{{_global.bizppurio_binding_modal?.template_code ?? ''}}",
+ "template_name": "{{_global.bizppurio_binding_modal?.template_name ?? ''}}",
+ "fallback_sms_enabled": "{{_global.bizppurio_binding_modal?.fallback_sms ?? false}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioBindings"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.binding.saved",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_binding"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bizppurio_binding_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.binding.save_error",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "init_actions": [
+ {
+ "comment": "알림톡 탭 진입 시 연결 맵 조회(행 연결 상태 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioBindings"
+ }
+ }
+ ],
+ "priority": 320
+}
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx
new file mode 100644
index 00000000..a76d8f43
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx
@@ -0,0 +1,309 @@
+/**
+ * 비즈뿌리오 메시징 플러그인 알림톡 템플릿 조회 탭 구조 검증 (조회 전용)
+ *
+ * plugin_settings.json 안에 탭으로 배치된 알림톡 템플릿 화면을 검증한다.
+ * 조회 전용 전환: 등록·수정·삭제·검수·상태변경을 제거하고 목록·상태·내용 조회 + 알림 연결만
+ * 남겼다. 등록·관리는 비즈뿌리오 콘솔로 위임한다.
+ * - 탭 네비게이션(환경설정 ↔ 알림톡 템플릿) + 배타 전환(query.tab)
+ * - 목록 서브뷰(상태필터·검색·상태배지·[내용] 버튼) — [새 템플릿]·폼·상태변경 없음
+ * - 상세 모달([닫기]만, 관리 액션 없음) / readiness 안내 / 준비 안내 / i18n 정합
+ *
+ * 독립 페이지·메뉴 없음: 설정 페이지 탭으로만 진입.
+ */
+
+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('알림톡 탭 버튼이 navigate replace 로 query.tab=templates 를 갱신하고 목록을 refetch 한다', () => {
+ const raw = JSON.stringify(findById(root, 'tab_templates'));
+ // 탭 전환은 navigate replace(+mergeQuery)로 if 재평가 → 화면 전환 (replaceUrl 은 if 미재평가라 부적합)
+ expect(raw).toContain('"handler":"navigate"');
+ expect(raw).toContain('"replace":true');
+ expect(raw).toContain('"tab":"templates"');
+ 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).toBeUndefined();
+ });
+});
+
+describe('alimtalk templates — 조회 전용 (폼·관리 제거)', () => {
+ it('폼 서브뷰(templates_form_view)가 제거되었다', () => {
+ expect(findById(root, 'templates_form_view')).toBeNull();
+ });
+
+ it('상태변경 확인/실행 모달(alimtalk_template_action_modal)이 제거되었다', () => {
+ const modals = (root as { modals?: AnyNode[] }).modals ?? [];
+ expect(modals.find((m) => m.id === 'alimtalk_template_action_modal')).toBeUndefined();
+ });
+
+ it('폼 전환·관리 상태(templateView/templateForm/pendingAction/available_actions)가 레이아웃에 없다', () => {
+ const raw = JSON.stringify(root);
+ expect(raw).not.toContain('templateView');
+ expect(raw).not.toContain('templateForm');
+ expect(raw).not.toContain('pendingAction');
+ expect(raw).not.toContain('available_actions');
+ // 이미지 업로드 핸들러도 제거
+ expect(raw).not.toContain('uploadTemplateImage');
+ });
+});
+
+describe('alimtalk templates — 목록 서브뷰 (조회 전용)', () => {
+ it('상태 필터 Select 와 검색 입력이 있다', () => {
+ const toolbar = findById(root, 'templates_toolbar');
+ const raw = JSON.stringify(toolbar);
+ expect(raw).toContain('templateStatus');
+ expect(raw).toContain('templateKeyword');
+ });
+
+ it('툴바에 [새 템플릿] 등록 버튼이 없고 새로고침만 있다 (캐시 초기화는 환경설정 탭으로 이관)', () => {
+ const raw = JSON.stringify(findById(root, 'templates_toolbar'));
+ expect(raw).toContain('templates.list.refresh');
+ // 캐시 초기화·캐시 시간은 환경설정 탭으로 옮겼으므로 템플릿 툴바에는 없다.
+ expect(raw).not.toContain('cache/clear');
+ // 등록 진입(폼 전환) 없음
+ expect(raw).not.toContain('templates.list.new');
+ expect(raw).not.toContain('"form"');
+ });
+
+ 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_table_card'));
+ // 상세 조회 → 상세 모달
+ expect(raw).toContain('templates.actions.detail');
+ expect(raw).toContain('"handler":"apiCall"');
+ expect(raw).toContain('alimtalk_template_detail_modal');
+ // 관리 액션 메뉴(ActionMenu)·switch 분기 없음
+ expect(raw).not.toContain('ActionMenu');
+ expect(raw).not.toContain('"handler":"switch"');
+ expect(raw).not.toContain("id:'edit'");
+ expect(raw).not.toContain("id:'delete'");
+ });
+
+ it('목록에 번호·등록요청일·처리일 컬럼이 있다', () => {
+ const raw = JSON.stringify(findById(root, 'templates_table_card'));
+ expect(raw).toContain('columns.no');
+ expect(raw).toContain('columns.requested_at');
+ expect(raw).toContain('columns.processed_at');
+ // 순번은 pagination 기준 계산 + index_var
+ expect(raw).toContain('"index_var":"tplIndex"');
+ expect(raw).toContain('pagination?.current_page');
+ // 날짜는 kapi 원본 필드
+ expect(raw).toContain('tpl.createdAt');
+ expect(raw).toContain('tpl.modifiedAt');
+ });
+
+ it('상태 배지가 RDY 일 때만 세부(사용전)를 덧붙인다', () => {
+ const raw = JSON.stringify(findById(root, 'templates_table_card'));
+ expect(raw).toContain("tpl.service_status === 'RDY'");
+ expect(raw).toContain('status_sub.rdy');
+ });
+
+ it('페이지네이션이 page 상태를 갱신하고 목록을 refetch 한다', () => {
+ const listView = JSON.stringify(findById(root, 'templates_list_view'));
+ expect(listView).toContain('"name":"Pagination"');
+ expect(listView).toContain('onPageChange');
+ expect(listView).toContain('templatePage');
+ expect(listView).toContain('pagination?.total_page');
+ });
+});
+
+describe('alimtalk templates — 콘솔 안내 (조회 전용)', () => {
+ it('목록 상단 안내가 배지 의미(제목+배지명+설명) + 콘솔 위임 안내를 포함한다', () => {
+ const raw = JSON.stringify(findById(root, 'templates_list_notice'));
+ // 배지 의미 — 제목 + 배지명/설명 분리(의미색 라벨)
+ expect(raw).toContain('status_guide.title');
+ expect(raw).toContain('status_guide.sendable_label');
+ expect(raw).toContain('status_guide.inspecting_label');
+ expect(raw).toContain('status_guide.pending_label');
+ // 배지명은 의미색(초록/amber/빨강) solid 로 표의 배지와 매칭
+ expect(raw).toContain('text-green-700');
+ expect(raw).toContain('text-amber-700');
+ expect(raw).toContain('text-red-700');
+ // 콘솔 위임 안내 + 콘솔 링크
+ expect(raw).toContain('list_notice.console_desc');
+ expect(raw).toContain('list_notice.console_link');
+ expect(raw).toContain('bizppurio.com');
+ });
+
+ it('환경설정 탭에 사용 전 준비 안내(카카오·SMS 통합 1박스)가 있고 info_panel 은 제거되었다', () => {
+ const notice = findById(root, 'preparation_notice');
+ expect(notice).toBeTruthy();
+ const raw = JSON.stringify(notice);
+ // 통합 박스: 문자(발신번호) + 카카오(채널·템플릿·API키 3단계) + 콘솔 링크 (박스 제목 없이 채널 그룹만)
+ expect(raw).toContain('preparation.sms_label');
+ expect(raw).toContain('preparation.sms_sender');
+ expect(raw).toContain('preparation.kakao_label');
+ expect(raw).toContain('preparation.kakao_channel');
+ expect(raw).toContain('preparation.kakao_template');
+ expect(raw).toContain('preparation.kakao_apikey');
+ expect(raw).toContain('preparation.console_link');
+ // 제목·본문 sm 로 상향(가독성)
+ expect(raw).toContain('text-sm');
+ // 중복이던 연동정보 안내(info_panel)는 제거(운영전환 경고·리포트 안내는 폼/리포트 섹션이 담당)
+ expect(findById(root, 'info_panel')).toBeNull();
+ });
+});
+
+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('상세 모달이 상태 배지·내용을 표시하고 [닫기]만 노출한다 (관리 액션 없음)', () => {
+ const modals = (root as { modals?: AnyNode[] }).modals ?? [];
+ const detail = JSON.stringify(modals.find((m) => m.id === 'alimtalk_template_detail_modal'));
+ // 상태 배지 + 내용(카테고리·유형·버튼) 표시
+ expect(detail).toContain('status_badge');
+ expect(detail).toContain('templates.detail.category');
+ expect(detail).toContain('templates.detail.buttons');
+ expect(detail).toContain('columns.requested_at');
+ // 닫기만 — 관리 액션(available_actions)·상태변경 모달 연결 없음
+ expect(detail).toContain('templates.detail.close');
+ expect(detail).not.toContain('available_actions');
+ expect(detail).not.toContain('alimtalk_template_action_modal');
+ });
+
+ it('readiness 안내가 미준비(ready=false) 시 조건부로 표시되고 항목별 상태를 노출한다', () => {
+ const readiness = findById(root, 'templates_readiness');
+ // 최상위 노출 조건은 ready 플래그 기반
+ const cond = (readiness as { if?: string }).if ?? '';
+ expect(cond).toContain('templates_readiness?.data');
+ expect(cond).toContain('ready');
+ // 자식 노드에서 항목별 미설정(api_key_set/sender_key_set) 조건부 표시
+ const raw = JSON.stringify(readiness);
+ expect(raw).toContain('api_key_set');
+ expect(raw).toContain('sender_key_set');
+ });
+});
+
+describe('alimtalk templates — 회귀', () => {
+ it('컴포넌트 최상위 actions 는 모두 이벤트 type 을 가진다 (charAt 회귀 방지)', () => {
+ // 회귀: 컴포넌트의 actions 배열 최상위 항목에 type(click 등) 이 없으면 엔진이
+ // getReactEventName 에서 eventType.charAt(0) 을 호출하다 "Cannot read properties of
+ // undefined (reading 'charAt')" 로 그 컴포넌트 렌더가 통째로 실패한다(PO 브라우저 검수로 발견).
+ const bad: string[] = [];
+ const walk = (node: AnyNode, path: string): void => {
+ if (Array.isArray(node)) {
+ node.forEach((n, i) => walk(n as AnyNode, `${path}[${i}]`));
+ return;
+ }
+ if (node && typeof node === 'object') {
+ const obj = node as Record;
+ // 컴포넌트의 actions 만 검사 (sequence/switch 내부 하위 actions 는 handler 를 가짐 → 제외)
+ if (Array.isArray(obj.actions) && obj.handler === undefined) {
+ (obj.actions as Array>).forEach((a, i) => {
+ if (a && typeof a === 'object' && a.type === undefined) {
+ bad.push(`${path}.actions[${i}] handler=${String(a.handler)}`);
+ }
+ });
+ }
+ for (const k of Object.keys(obj)) walk(obj[k] as AnyNode, `${path}.${k}`);
+ }
+ };
+ walk(root, 'root');
+ expect(bad, `type 누락 최상위 액션:\n${bad.join('\n')}`).toEqual([]);
+ });
+});
+
+describe('alimtalk templates — i18n 정합', () => {
+ it('레이아웃 $t: 키가 ko/en 다국어 파일에 모두 존재한다', () => {
+ const keys = collectI18nKeys(layout);
+ const resolve = (dict: Record, path: string): unknown =>
+ path.split('.').reduce((acc, seg) => {
+ if (acc && typeof acc === 'object') {
+ return (acc as Record)[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();
+ }
+ });
+});
+
+describe('alimtalk templates — 환경설정 탭: 발송 내용 캐시 (독립 카드)', () => {
+ it('발송 내용 캐시가 독립 카드로 있고 캐시 시간(분) 입력을 담는다 (form 저장 대상)', () => {
+ const card = findById(root, 'cache_section');
+ expect(card).toBeTruthy();
+ const raw = JSON.stringify(card);
+ // 카드 제목 + 분 단위 숫자 입력 + form 바인딩(name)
+ expect(raw).toContain('settings.cache.section_title');
+ expect(raw).toContain('template_cache_minutes');
+ expect(raw).toContain('"number"');
+ expect(raw).toContain('settings.fields.template_cache_minutes.label');
+ expect(raw).toContain('settings.fields.template_cache_minutes.hint');
+ });
+
+ it('같은 카드에 캐시 초기화 버튼 + 안내문이 함께 있다 (한 묶음)', () => {
+ const raw = JSON.stringify(findById(root, 'cache_section'));
+ // 즉시 캐시 비우기 — API 호출 + 성공/실패 토스트 + 상시 안내문
+ expect(raw).toContain('alimtalk-templates/cache/clear');
+ expect(raw).toContain('apiCall');
+ expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache');
+ expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_hint');
+ expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_success');
+ expect(raw).toContain('settings.fields.template_cache_minutes.clear_cache_failed');
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts
new file mode 100644
index 00000000..74d07367
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts
@@ -0,0 +1,120 @@
+/**
+ * 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼
+ *
+ * JSON 트리에서 ID/이름 검색 및 핸들러·i18n 키 추출을 위한 유틸리티.
+ */
+
+export type AnyNode = Record & {
+ id?: string;
+ name?: string;
+ type?: string;
+ children?: AnyNode[];
+ slots?: Record;
+ modals?: Record | AnyNode[];
+ actions?: AnyNode[];
+ iteration?: { source?: string; item_var?: string; index_var?: string };
+ if?: string;
+ props?: Record;
+ 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));
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx
new file mode 100644
index 00000000..fb0df8cb
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx
@@ -0,0 +1,339 @@
+// e2e:allow 검수 라벨 표시(is_test_mode)는 Chrome MCP로 실제 회원가입→SMS 발송→화면 렌더까지
+// 실측 검증 완료(2026-07-24). 정식 E2E는 비즈뿌리오 자격증명·채널 활성화·webhook 등 발송 인프라
+// 의존이 커서 이번 변경 범위를 벗어나며, 별도 계획(plan-e2e-tests)에서 다룰 예정.
+/**
+ * 코어 알림 발송 이력 결과 컬럼 주입 렌더 테스트 (A-2)
+ *
+ * notification_log_result.json overlay 는 코어 "알림 발송 이력" 화면(admin_notification_log_list)의
+ * DataGrid columns 에 결과 컬럼 1개를 _append 로 얹는다. 코어 화면·코어 앱·코어 테이블 무수정 —
+ * 현재 페이지의 코어 로그 id 배열로 결과(dispatchResults)를 배치 조회해 row.id 로 매칭한다.
+ *
+ * 이 테스트는 overlay 에서 결과 컬럼 정의를 그대로 추출해 실제 렌더한다(구조 검증이 아니라 렌더).
+ * row 컨텍스트는 iteration 으로 재현하고, dispatchResults 는 mockApi 로 채운다. 각 케이스에서
+ * 올바른 배지 텍스트(상태 `사유 (코드)`·잔액부족·대체발송)가 DOM 에 뜨는지, 매칭 안 되는 행은
+ * 빈 셀(-)인지 확인한다.
+ *
+ * @vitest-environment jsdom
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import {
+ createLayoutTest,
+ createMockComponentRegistryWithBasics,
+ screen,
+ type MockComponentRegistry,
+} from '@core/template-engine/__tests__/utils/layoutTestUtils';
+
+import overlay from '../../../extensions/notification_log_result.json';
+
+/** overlay 에서 결과 컬럼(field: bizppurio_result)의 cellChildren 을 추출한다. */
+function getResultCellChildren(): any[] {
+ const inj = (overlay as any).injections.find(
+ (i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props',
+ );
+ const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result');
+ if (!column) throw new Error('결과 컬럼(bizppurio_result)을 찾지 못함');
+ return column.cellChildren;
+}
+
+/** overlay 에서 행 토글(expandChildren) 에 append 된 결과 블록을 추출한다. */
+function getResultExpandChildren(): any[] {
+ const inj = (overlay as any).injections.find(
+ (i: any) => i.target_id === 'notification_log_datagrid' && i.position === 'inject_props',
+ );
+ return inj.props.expandChildren._append;
+}
+
+/** 결과 컬럼 cellChildren 을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */
+function buildProbe() {
+ return {
+ version: '1.0.0',
+ layout_name: 'test/a2-dispatch-result',
+ data_sources: [
+ { id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true },
+ { id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true },
+ ],
+ components: [
+ {
+ type: 'basic',
+ name: 'Div',
+ iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' },
+ props: { 'data-testid': 'result-cell' },
+ children: getResultCellChildren(),
+ },
+ ],
+ };
+}
+
+/** 행 토글(expandChildren) 결과 블록을 iteration row 컨텍스트로 렌더하는 프로브 레이아웃. */
+function buildExpandProbe() {
+ return {
+ version: '1.0.0',
+ layout_name: 'test/a2-dispatch-result-expand',
+ data_sources: [
+ { id: 'notificationLogs', type: 'api', endpoint: '/api/test/logs', method: 'GET', auto_fetch: true },
+ { id: 'dispatchResults', type: 'api', endpoint: '/api/test/results', method: 'POST', auto_fetch: true },
+ ],
+ components: [
+ {
+ type: 'basic',
+ name: 'Div',
+ iteration: { source: '{{notificationLogs.data?.data ?? []}}', item_var: 'row' },
+ props: { 'data-testid': 'result-expand' },
+ children: getResultExpandChildren(),
+ },
+ ],
+ };
+}
+
+let registry: MockComponentRegistry;
+beforeEach(() => {
+ registry = createMockComponentRegistryWithBasics();
+});
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+/** 로그 행 목록. 셀 탭 가드가 row.channel 을 보므로 채널을 함께 준다(기본 sms). */
+const logs = (ids: number[], channel: string = 'sms') => ({ data: { data: ids.map((id) => ({ id, channel })) } });
+
+describe('A-2 결과 컬럼 주입 — overlay 구조', () => {
+ it('코어 알림 발송 이력 datagrid 에 결과 컬럼을 _append 로 얹는다', () => {
+ expect((overlay as any).target_layout).toBe('admin_notification_log_list');
+ const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
+ expect(inj.position).toBe('inject_props');
+ expect(inj.props.columns._append).toBeTruthy();
+ });
+
+ it('결과는 파라미터 없이 GET 으로 최근 결과 맵을 받는다(타이밍 무관, kginicis 선례)', () => {
+ // 다른 data_source(notificationLogs)를 params 로 참조하면, params 가 notificationLogs 응답
+ // 도착 전에 평가돼 빈 배열이 전송되는 타이밍 결함이 있다(브라우저 실측: 결과 컬럼 전부 빈 셀).
+ // 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭한다(로드 순서 무관).
+ const ds = (overlay as any).data_sources.find((d: any) => d.id === 'dispatchResults');
+ expect(ds.method).toBe('GET');
+ expect(ds.endpoint).toContain('/dispatch-results/recent');
+ expect(ds.params).toBeUndefined();
+ expect(ds.if).toBeUndefined();
+ });
+
+ it('결과 컬럼 셀은 비즈뿌리오 발송 행(row.channel)에서만 표시한다(메일·사이트내알림 숨김)', () => {
+ const cell = getResultCellChildren();
+ const guard = cell[0];
+ // 셀 렌더 컨텍스트는 row/value 만 있고 query 는 없다(코어 DataGrid renderCellChildren 계약).
+ // 따라서 탭(query.channel)이 아니라 row.channel 로 비즈뿌리오 발송 행을 판별한다.
+ expect(guard.if).toContain("['sms','lms','alimtalk'].includes(row.channel)");
+ });
+
+ it('컬럼(헤더 포함)은 메일·사이트내알림 탭에서 hidden 으로 숨긴다', () => {
+ // 컬럼 정의는 datagrid props 로 페이지 컨텍스트에서 평가되므로 query 접근 가능(셀과 다름).
+ // 메일·사이트내알림(mail/database) 탭이면 hidden=true → 헤더까지 숨긴다.
+ const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
+ const column = inj.props.columns._append.find((c: any) => c.field === 'bizppurio_result');
+ expect(column.hidden).toContain("['mail','database'].includes(query.channel");
+ });
+
+ it('행 토글(expandChildren)에도 결과 블록을 append 한다(비즈뿌리오 발송 행만)', () => {
+ const inj = (overlay as any).injections.find((i: any) => i.target_id === 'notification_log_datagrid');
+ const appended = inj.props.expandChildren._append;
+ expect(appended).toBeTruthy();
+ const block = appended[0];
+ // 결과가 매칭된 행일 때만 토글에 노출.
+ expect(block.if).toContain('dispatchResults?.data?.results');
+ expect(JSON.stringify(block)).toContain('dispatch_result.detail_title');
+ });
+
+ it('배지는 다크모드에서 solid 색을 쓴다(색-불투명도 희석 금지)', () => {
+ const raw = JSON.stringify(overlay);
+ // /40 등 색-불투명도는 작은 배지에서 다크 배경과 섞여 묽어지므로 solid(dark:bg-*-700 등) 사용.
+ expect(raw).toContain('dark:bg-green-700');
+ expect(raw).toContain('dark:bg-red-700');
+ expect(raw).not.toContain('dark:bg-green-900/40');
+ });
+});
+
+describe('A-2 결과 컬럼 주입 — 렌더', () => {
+ it('성공 결과는 사유(코드) 라벨을 렌더한다', async () => {
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([1]) });
+ utils.mockApi('dispatchResults', {
+ response: { data: { results: { 1: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } },
+ });
+ await utils.render();
+ expect(screen.getByText('성공 (4100)')).toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('잔액부족 실패는 사유(코드) 라벨과 잔액부족 배지를 렌더한다', async () => {
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([2]) });
+ utils.mockApi('dispatchResults', {
+ response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '지갑 잔액 부족 (7436)', is_low_balance: true, fallback_status: null } } } },
+ });
+ await utils.render();
+ // result_label 은 data 값(그대로 렌더). 잔액부족 배지는 is_low_balance=true 조건부 렌더 —
+ // $t: 라벨은 이 렌더 환경에서 원문 키로 남으므로 그 키 존재로 배지 렌더를 확인한다.
+ expect(screen.getByText('지갑 잔액 부족 (7436)')).toBeInTheDocument();
+ expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('is_low_balance=false 이면 잔액부족 배지를 렌더하지 않는다', async () => {
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([2]) });
+ utils.mockApi('dispatchResults', {
+ response: { data: { results: { 2: { status: 'failed', status_label: '실패', result_label: '음영 지역 (4400)', is_low_balance: false, fallback_status: null } } } },
+ });
+ await utils.render();
+ expect(screen.getByText('음영 지역 (4400)')).toBeInTheDocument();
+ expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.low_balance')).not.toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('대체발송 결과가 있으면 대체발송 배지를 렌더한다', async () => {
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([3]) });
+ utils.mockApi('dispatchResults', {
+ response: { data: { results: { 3: { status: 'success', status_label: '성공', result_label: '성공 (7000)', is_low_balance: false, fallback_status: '성공' } } } },
+ });
+ await utils.render();
+ // 대체발송 배지는 fallback_status 존재 시 조건부 렌더 ($t: 라벨은 원문 키로 남음).
+ expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.fallback')).toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('비즈뿌리오 발송(sms) 행이지만 결과 미매칭이면 빈 셀(-)을 렌더한다', async () => {
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([9], 'sms') });
+ utils.mockApi('dispatchResults', { response: { data: { results: {} } } });
+ await utils.render();
+ expect(screen.getByText('-')).toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('메일 채널 행은 셀 자체를 비운다(빈 셀 - 도 표시 안 함)', async () => {
+ // row.channel 이 mail 이면 셀 최상위 가드가 false → 셀 내용(빈 셀 - 포함) 전체 미렌더.
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([10], 'mail') });
+ utils.mockApi('dispatchResults', { response: { data: { results: {} } } });
+ await utils.render();
+ expect(screen.queryByText('-')).not.toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('검수 모드 발송 건은 상태 라벨(발송중) 대신 검수 라벨을 렌더한다', async () => {
+ // is_test_mode=true 이면 status='sent'(발송중)이어도 검수 라벨로 대체 표시한다(PO 확정 —
+ // "발송중" 문구 자체가 검수 모드에서는 오해 소지라 배지 병기가 아니라 라벨 교체).
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([4]) });
+ utils.mockApi('dispatchResults', {
+ response: { data: { results: { 4: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: true } } } },
+ });
+ await utils.render();
+ // $t('key') 는 이 렌더 환경에서 번역 실패 시 원문 키를 그대로 반환한다($t: 와 동일 폴백).
+ expect(screen.getByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).toBeInTheDocument();
+ expect(screen.queryByText('발송중')).not.toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('운영 모드 발송 건은 검수 라벨 없이 기존 상태 라벨을 그대로 렌더한다', async () => {
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([5]) });
+ utils.mockApi('dispatchResults', {
+ response: { data: { results: { 5: { status: 'sent', status_label: '발송중', result_label: null, is_low_balance: false, fallback_status: null, is_test_mode: false } } } },
+ });
+ await utils.render();
+ expect(screen.getByText('발송중')).toBeInTheDocument();
+ expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('is_test_mode 필드가 없는 과거 이력은 검수 라벨 없이 기존 라벨을 렌더한다', async () => {
+ // 컬럼 신설 이전 이력(is_test_mode 미포함)도 undefined === true 가 false 이므로 안전하게 기존 라벨.
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([6]) });
+ utils.mockApi('dispatchResults', {
+ response: { data: { results: { 6: { status: 'success', status_label: '성공', result_label: '성공 (4100)', is_low_balance: false, fallback_status: null } } } },
+ });
+ await utils.render();
+ expect(screen.getByText('성공 (4100)')).toBeInTheDocument();
+ expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.inspection_label')).not.toBeInTheDocument();
+ utils.cleanup();
+ });
+});
+
+describe('A-2 행 토글 — 알림톡 실제 발송 내용', () => {
+ it('알림톡 채널 + 실제 발송 내용이 있으면 코어 "본문"과 별도로 렌더한다', async () => {
+ const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([7], 'alimtalk') });
+ utils.mockApi('dispatchResults', {
+ response: {
+ data: {
+ results: {
+ 7: {
+ status: 'success',
+ status_label: '성공',
+ result_label: '성공 (7000)',
+ is_low_balance: false,
+ fallback_status: null,
+ channel: 'alimtalk',
+ content: '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.',
+ },
+ },
+ },
+ },
+ });
+ await utils.render();
+ expect(screen.getByText(/회원가입을 환영합니다/)).toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('sms 채널은 실제 발송 내용 값이 있어도 렌더하지 않는다(코어 본문과 동일하므로 중복 표시 불필요)', async () => {
+ const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([8], 'sms') });
+ utils.mockApi('dispatchResults', {
+ response: {
+ data: {
+ results: {
+ 8: {
+ status: 'success',
+ status_label: '성공',
+ result_label: '성공 (4100)',
+ is_low_balance: false,
+ fallback_status: null,
+ channel: 'sms',
+ content: '문자 본문',
+ },
+ },
+ },
+ },
+ });
+ await utils.render();
+ expect(screen.queryByText('문자 본문')).not.toBeInTheDocument();
+ utils.cleanup();
+ });
+
+ it('알림톡 채널이지만 실제 발송 내용이 없으면(과거 이력 등) 렌더하지 않는다', async () => {
+ const utils = createLayoutTest(buildExpandProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('notificationLogs', { response: logs([9], 'alimtalk') });
+ utils.mockApi('dispatchResults', {
+ response: {
+ data: {
+ results: {
+ 9: {
+ status: 'success',
+ status_label: '성공',
+ result_label: '성공 (7000)',
+ is_low_balance: false,
+ fallback_status: null,
+ channel: 'alimtalk',
+ content: null,
+ },
+ },
+ },
+ },
+ });
+ await utils.render();
+ expect(screen.queryByText('sirsoft-message_bizppurio.dispatch_result.sent_content_label')).not.toBeInTheDocument();
+ utils.cleanup();
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx
new file mode 100644
index 00000000..f68e83cf
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx
@@ -0,0 +1,238 @@
+// e2e:allow 검수 모드 배너(readiness 무관 노출)·배너 간격은 Chrome MCP로 알림톡 탭 실제 화면
+// 확인·수정까지 완료(2026-07-24). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸.
+/**
+ * 알림 설정 알림톡 탭 연동 UI 구조 검증 (Phase 6 재설계, §6-2)
+ *
+ * 두 확장 파일로 분리 구현:
+ * - notification_tab_core.json (Overlay): 상태 배너(injections) + 안내 박스 + 연결 모달(modals)
+ * + data_sources. target_layout=admin_settings.
+ * - notification_row_footer.json (ExtensionPoint): 코어 목록 각 행 하단 슬롯
+ * (notification_definition_row_footer)에 연결 상태 줄 + [연결/변경] 버튼.
+ *
+ * 코어 편집 모달·저장 버튼은 건드리지 않는다(무오염). 연결은 편집 모달과 분리된 우리 전용
+ * 모달에서 하며, 변경 즉시가 아니라 [저장] 버튼으로 명확히 저장한다. 카카오 API 422 는
+ * errorHandling.suppress 로 조용히 처리(안내는 배너·문구가 담당).
+ *
+ * 검증: 파일 분리(overlay vs extension_point), 배너/안내/모달 구조, 행 슬롯 UI, 저장 배선,
+ * 무오염(코어 저장 body 미개입), i18n 정합.
+ */
+
+import { describe, it, expect } from 'vitest';
+import overlay from '../../../extensions/notification_tab_core.json';
+import footer from '../../../extensions/notification_row_footer.json';
+import ko from '../../../lang/ko.json';
+import en from '../../../lang/en.json';
+import { findById, type AnyNode } from './helpers';
+
+const overlayRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
+const bannerRoot = {
+ children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? []).flatMap((i) => i.components ?? []),
+} as AnyNode;
+const footerRoot = { children: (footer as { components?: AnyNode[] }).components ?? [] } as AnyNode;
+
+/** overlay 텍스트에서 $t:key 및 $t('key') 형태의 플러그인 i18n 키를 모두 수집한다. */
+const collectPluginKeys = (json: unknown): string[] => {
+ const text = JSON.stringify(json);
+ const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
+ const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? [];
+ return Array.from(new Set([
+ ...prefixed.map((m) => m.replace('$t:', '')),
+ ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
+ ]));
+};
+
+describe('binding UI — 파일 분리(Overlay vs ExtensionPoint)', () => {
+ it('overlay 는 target_layout=admin_settings 이고 extension_point 키가 없다', () => {
+ expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings');
+ expect((overlay as Record).extension_point).toBeUndefined();
+ });
+
+ it('footer 는 extension_point=notification_definition_row_footer 이고 target_layout 이 없다', () => {
+ expect((footer as { extension_point?: string }).extension_point).toBe('notification_definition_row_footer');
+ expect((footer as Record).target_layout).toBeUndefined();
+ });
+
+ it('overlay 는 연결 맵·승인 템플릿 데이터소스를 등록한다', () => {
+ const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
+ expect(ids).toContain('bizppurioBindings');
+ expect(ids).toContain('bizppurioApprovedTemplates');
+ });
+
+ it('승인 템플릿 데이터소스는 auto_fetch:false 이고 422 를 suppress 한다(전 탭 에러 방지)', () => {
+ const ds = ((overlay as { data_sources?: Array> }).data_sources ?? [])
+ .find((d) => d.id === 'bizppurioApprovedTemplates');
+ expect(ds?.auto_fetch).toBe(false);
+ expect(JSON.stringify(ds?.errorHandling)).toContain('suppress');
+ });
+});
+
+describe('binding UI — 상태 배너 + 안내 박스', () => {
+ it('배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
+ const banner = findById(bannerRoot, 'bizppurio_status_banner');
+ expect(banner).toBeTruthy();
+ const cond = (banner as { if?: string }).if ?? '';
+ expect(cond).toContain("'sms'");
+ expect(cond).toContain("'alimtalk'");
+ expect(cond).toContain('readiness?.ready === false');
+ expect(cond).toContain('is_test_mode === true');
+ });
+
+ it('빨강(설정 미완료)·노랑(검수 모드) 배너가 동시에 뜰 때 간격이 있다(회귀: 두 배너가 붙어 보이던 문제)', () => {
+ const banner = findById(bannerRoot, 'bizppurio_status_banner');
+ const className = (banner as { props?: { className?: string } }).props?.className ?? '';
+ expect(className).toMatch(/space-y-\d/);
+ });
+
+ it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
+ const raw = JSON.stringify(findById(bannerRoot, 'bizppurio_banner_not_ready'));
+ expect(raw).toContain('banner.not_ready');
+ expect(raw).toContain('banner.setup_action');
+ expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings');
+ });
+
+ it('검수 모드 배너는 readiness 충족 여부와 무관하게 is_test_mode 만으로 노출된다', () => {
+ // readiness 실패(예: 알림톡 API 키 미설정) + 검수 모드가 동시에 참인 상황에서도
+ // 검수 안내가 가려지면 안 된다(회귀: 과거 readiness?.ready !== false 조건이 배너를 숨겼음).
+ const banner = findById(bannerRoot, 'bizppurio_banner_test_mode');
+ expect(banner).toBeTruthy();
+ const cond = (banner as { if?: string }).if ?? '';
+ expect(cond).not.toContain('readiness');
+ expect(cond).toContain('is_test_mode === true');
+ });
+
+ it('알림톡 탭 상시 안내 박스가 있다(무엇을 하는 화면인지)', () => {
+ const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide');
+ expect(guide).toBeTruthy();
+ expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
+ expect(JSON.stringify(guide)).toContain('binding.list_guide');
+ });
+});
+
+describe('binding UI — 행 연결(extension_point)', () => {
+ it('행 연결 UI 는 channel === alimtalk 일 때만 노출된다', () => {
+ const row = findById(footerRoot, 'bizppurio_row_binding');
+ expect(row).toBeTruthy();
+ expect((row as { if?: string }).if).toContain("extensionPointProps.activeChannel === 'alimtalk'");
+ });
+
+ it('연결 상태를 bizppurioBindings 에서 def.type 으로 읽어 표시한다(연결됨/미연결)', () => {
+ const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
+ expect(raw).toContain('bizppurioBindings?.data?.bindings?.[extensionPointProps.definition?.type]');
+ expect(raw).toContain('binding.unbound');
+ expect(raw).toContain('binding.btn_connect');
+ expect(raw).toContain('binding.btn_change');
+ });
+
+ it('[연결] 클릭 시 모달 상태를 seed 하고 우리 연결 모달을 연다', () => {
+ const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
+ expect(raw).toContain('bizppurio_binding_modal');
+ expect(raw).toContain('"openModal"');
+ expect(raw).toContain('modal_bizppurio_binding');
+ });
+
+ it('연결된 카카오 템플릿이 소실(is_unavailable)이면 빨간 경고 배지를 표시한다(결함 2)', () => {
+ const raw = JSON.stringify(findById(footerRoot, 'bizppurio_row_binding'));
+ // 연결됨(template_code 있음) + is_unavailable === true 일 때만 경고
+ expect(raw).toContain('is_unavailable === true');
+ expect(raw).toContain('binding.unavailable');
+ // 소실 경고는 red 배지로 표시(연결됨 초록과 구분)
+ expect(raw).toContain('bg-red-100');
+ });
+
+ it('모달 열기(openModal)가 승인 템플릿 조회(refetch)보다 먼저 실행된다(조회 실패가 모달 표시를 막지 않도록)', () => {
+ const row = findById(footerRoot, 'bizppurio_row_binding');
+ const raw = JSON.stringify(row);
+ const openIdx = raw.indexOf('"openModal"');
+ const refetchIdx = raw.indexOf('bizppurioApprovedTemplates');
+ expect(openIdx).toBeGreaterThan(-1);
+ expect(refetchIdx).toBeGreaterThan(-1);
+ expect(openIdx).toBeLessThan(refetchIdx);
+ });
+});
+
+describe('binding UI — 연결 모달(우리 소유, 코어 편집 모달과 분리)', () => {
+ const modal = findById(overlayRoot, 'modal_bizppurio_binding');
+
+ it('연결 전용 모달이 modals 로 등록된다', () => {
+ expect(modal).toBeTruthy();
+ expect((modal as { name?: string }).name).toBe('Modal');
+ });
+
+ it('안내 카드 + 연결 템플릿 드롭다운 + SMS 대체 토글 + 변수 안내를 담는다', () => {
+ const raw = JSON.stringify(modal);
+ expect(raw).toContain('binding.section_hint');
+ expect(raw).toContain('binding.connected_template');
+ expect(raw).toContain('binding.fallback_sms');
+ expect(raw).toContain('binding.variables_hint');
+ });
+
+ it('SMS 대체 토글은 연결 템플릿이 없으면 비활성이다', () => {
+ const raw = JSON.stringify(findById(overlayRoot, 'bizppurio_binding_modal_body'));
+ expect(raw).toContain('"disabled"');
+ expect(raw).toContain("=== ''");
+ });
+
+ it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
+ const raw = JSON.stringify(modal);
+ expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
+ expect(raw).toContain('"method":"POST"');
+ expect(raw).toContain('binding.saved');
+ expect(raw).toContain('binding.save_error');
+ expect(raw).toContain('"closeModal"');
+ expect(raw).toContain('bizppurioBindings');
+ });
+});
+
+describe('binding UI — 드롭다운 조회 실패 vs 0건 구분(결함 3)', () => {
+ const modal = findById(overlayRoot, 'modal_bizppurio_binding');
+
+ it('승인 템플릿 데이터소스 fallback 에 load_failed:true 마커가 있다', () => {
+ const ds = ((overlay as { data_sources?: Array> }).data_sources ?? [])
+ .find((d) => d.id === 'bizppurioApprovedTemplates');
+ const fallback = (ds?.fallback as { data?: Record })?.data ?? {};
+ // 조회 실패 시 이 마커가 상태에 실려 '0건'과 구분된다. 정상 응답에는 이 필드가 없다.
+ expect(fallback.load_failed).toBe(true);
+ expect(Array.isArray(fallback.templates)).toBe(true);
+ expect((fallback.templates as unknown[]).length).toBe(0);
+ });
+
+ it('드롭다운이 비었을 때 조회 실패(load_failed)면 설정 확인 문구를 노출한다', () => {
+ const raw = JSON.stringify(modal);
+ // 조회 실패 분기: length===0 && load_failed===true → templates_load_failed
+ expect(raw).toContain('binding.templates_load_failed');
+ expect(raw).toContain('load_failed === true');
+ });
+
+ it('드롭다운이 비었을 때 조회 정상(0건)이면 승인 템플릿 없음 문구를 노출한다', () => {
+ const raw = JSON.stringify(modal);
+ // 0건 분기: length===0 && !load_failed → no_approved_templates
+ expect(raw).toContain('binding.no_approved_templates');
+ expect(raw).toContain('!(bizppurioApprovedTemplates?.data?.load_failed)');
+ });
+
+ it('두 문구는 상호배타 조건이라 동시에 뜨지 않는다(조회실패=빨강 / 0건=amber)', () => {
+ const raw = JSON.stringify(modal);
+ // 조회 실패 문구는 red, 0건 문구는 amber 로 시각 구분
+ expect(raw).toContain('text-red-600');
+ expect(raw).toContain('text-amber-600');
+ });
+});
+
+describe('binding UI — 코어 무오염 + i18n', () => {
+ it('overlay·footer 어디에도 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
+ const all = JSON.stringify(overlay) + JSON.stringify(footer);
+ expect(all).not.toContain('/api/admin/notification-templates/');
+ expect(all).not.toContain('notification_template_form_modal');
+ });
+
+ it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
+ const keys = [...collectPluginKeys(overlay), ...collectPluginKeys(footer)];
+ expect(keys.length).toBeGreaterThan(0);
+ const resolve = (root: unknown, path: string): unknown =>
+ path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root);
+ for (const key of Array.from(new Set(keys))) {
+ expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
+ expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
+ }
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx
new file mode 100644
index 00000000..7e89914c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx
@@ -0,0 +1,167 @@
+// e2e:allow 게시판·이커머스 배너/버튼/모달 노출은 Chrome MCP 로 실브라우저 확인·수정까지 완료
+// (2026-07-28). 정식 E2E는 비즈뿌리오 발송 인프라 의존이 커서 별도 계획에서 다룸(코어와 동일 사유).
+/**
+ * 게시판·이커머스 알림 설정 알림톡 탭 연동 UI 구조 검증 (이슈 #28 후속)
+ *
+ * 배경: notification_tab_core.json(target_layout=admin_settings) + notification_row_footer.json
+ * (extension_point, 전역 매칭)은 코어 알림 설정 화면에만 연동 버튼·배너를 노출했다.
+ * extension_point 는 이름만 같으면 어느 레이아웃에서도 매칭되지만, target_id 기반 overlay
+ * injection 은 레이아웃별로 독립이라 게시판·이커머스는 배너/안내박스/연결모달이 뜨지 않았다.
+ *
+ * notification_tab_board.json / notification_tab_ecommerce.json 을 신설해
+ * target_layout=admin_board_settings / admin_ecommerce_settings 로 각각 등록했다.
+ * notification_row_footer.json 은 그대로(전역 매칭)이므로 재사용된다.
+ */
+
+import { describe, it, expect } from 'vitest';
+import boardOverlay from '../../../extensions/notification_tab_board.json';
+import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json';
+import ko from '../../../lang/ko.json';
+import en from '../../../lang/en.json';
+import { findById, type AnyNode } from './helpers';
+
+type OverlayFixture = {
+ label: string;
+ overlay: typeof boardOverlay;
+ targetLayout: string;
+ targetId: string;
+ bannerId: string;
+ notReadyId: string;
+ testModeId: string;
+ guideId: string;
+ modalBodyId: string;
+};
+
+const FIXTURES: OverlayFixture[] = [
+ {
+ label: '게시판',
+ overlay: boardOverlay,
+ targetLayout: 'admin_board_settings',
+ targetId: 'board_notif_channel_content',
+ bannerId: 'bizppurio_board_status_banner',
+ notReadyId: 'bizppurio_board_banner_not_ready',
+ testModeId: 'bizppurio_board_banner_test_mode',
+ guideId: 'bizppurio_board_alimtalk_guide',
+ modalBodyId: 'bizppurio_board_binding_modal_body',
+ },
+ {
+ label: '이커머스',
+ overlay: ecommerceOverlay,
+ targetLayout: 'admin_ecommerce_settings',
+ targetId: 'ecommerce_notif_channel_content',
+ bannerId: 'bizppurio_ecommerce_status_banner',
+ notReadyId: 'bizppurio_ecommerce_banner_not_ready',
+ testModeId: 'bizppurio_ecommerce_banner_test_mode',
+ guideId: 'bizppurio_ecommerce_alimtalk_guide',
+ modalBodyId: 'bizppurio_ecommerce_binding_modal_body',
+ },
+];
+
+const collectPluginKeys = (json: unknown): string[] => {
+ const text = JSON.stringify(json);
+ const prefixed = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
+ const called = text.match(/\$t\('sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+'\)/g) ?? [];
+ return Array.from(new Set([
+ ...prefixed.map((m) => m.replace('$t:', '')),
+ ...called.map((m) => m.replace(/^\$t\('/, '').replace(/'\)$/, '')),
+ ]));
+};
+
+describe.each(FIXTURES)('$label 알림톡 연동 overlay', ({
+ overlay, targetLayout, targetId, bannerId, notReadyId, testModeId, guideId, modalBodyId,
+}) => {
+ const injectionRoot = {
+ children: ((overlay as { injections?: Array<{ components?: AnyNode[] }> }).injections ?? [])
+ .flatMap((i) => i.components ?? []),
+ } as AnyNode;
+ const modalRoot = { children: (overlay as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
+
+ it(`target_layout=${targetLayout} 이고 extension_point 키가 없다(overlay 전용)`, () => {
+ expect((overlay as { target_layout?: string }).target_layout).toBe(targetLayout);
+ expect((overlay as Record).extension_point).toBeUndefined();
+ });
+
+ it(`배너·안내박스는 target_id=${targetId} 에 prepend_child 로 주입된다`, () => {
+ const injections = (overlay as { injections?: Array> }).injections ?? [];
+ expect(injections).toHaveLength(1);
+ expect(injections[0].target_id).toBe(targetId);
+ expect(injections[0].position).toBe('prepend_child');
+ });
+
+ it('연결 맵·승인 템플릿 데이터소스를 등록한다(코어와 동일 endpoint)', () => {
+ const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
+ expect(ids).toContain('bizppurioBindings');
+ expect(ids).toContain('bizppurioApprovedTemplates');
+ });
+
+ it('상태 배너는 sms·alimtalk 탭에서 문제(readiness 미충족 / test_mode)일 때만 노출된다', () => {
+ const banner = findById(injectionRoot, bannerId);
+ expect(banner).toBeTruthy();
+ const cond = (banner as { if?: string }).if ?? '';
+ expect(cond).toContain("'sms'");
+ expect(cond).toContain("'alimtalk'");
+ expect(cond).toContain('readiness?.ready === false');
+ expect(cond).toContain('is_test_mode === true');
+ });
+
+ it('readiness 미충족 배너에 설정하기 이동 버튼이 있다', () => {
+ const raw = JSON.stringify(findById(injectionRoot, notReadyId));
+ expect(raw).toContain('banner.not_ready');
+ expect(raw).toContain('banner.setup_action');
+ expect(raw).toContain('/admin/plugins/sirsoft-message_bizppurio/settings');
+ });
+
+ it('검수 모드 배너는 readiness 와 무관하게 is_test_mode 만으로 노출된다', () => {
+ const banner = findById(injectionRoot, testModeId);
+ expect(banner).toBeTruthy();
+ const cond = (banner as { if?: string }).if ?? '';
+ expect(cond).not.toContain('readiness');
+ expect(cond).toContain('is_test_mode === true');
+ });
+
+ it('알림톡 탭 상시 안내 박스가 있다', () => {
+ const guide = findById(injectionRoot, guideId);
+ expect(guide).toBeTruthy();
+ expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
+ expect(JSON.stringify(guide)).toContain('binding.list_guide');
+ });
+
+ it('연결 전용 모달(modal_bizppurio_binding)이 modals 로 등록된다', () => {
+ const modal = findById(modalRoot, 'modal_bizppurio_binding');
+ expect(modal).toBeTruthy();
+ expect((modal as { name?: string }).name).toBe('Modal');
+ });
+
+ it('[저장] 은 우리 API store 로 저장하고 toast + 모달 닫힘 + 목록 갱신한다', () => {
+ const modal = findById(modalRoot, 'modal_bizppurio_binding');
+ const raw = JSON.stringify(modal);
+ expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/notification-bindings');
+ expect(raw).toContain('"method":"POST"');
+ expect(raw).toContain('binding.saved');
+ expect(raw).toContain('"closeModal"');
+ expect(raw).toContain('bizppurioBindings');
+ });
+
+ it('연결 템플릿이 없으면 SMS 대체 토글이 비활성이다(코어와 동일 규칙)', () => {
+ const raw = JSON.stringify(findById(modalRoot, modalBodyId));
+ expect(raw).toContain('"disabled"');
+ expect(raw).toContain("=== ''");
+ });
+
+ it('overlay 는 코어 편집 모달 저장 body(notification-templates PUT)를 건드리지 않는다', () => {
+ const raw = JSON.stringify(overlay);
+ expect(raw).not.toContain('/api/admin/notification-templates/');
+ expect(raw).not.toContain('notification_template_form_modal');
+ });
+
+ it('참조하는 모든 플러그인 i18n 키가 ko·en 에 존재한다', () => {
+ const keys = collectPluginKeys(overlay);
+ expect(keys.length).toBeGreaterThan(0);
+ const resolve = (root: unknown, path: string): unknown =>
+ path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root);
+ for (const key of Array.from(new Set(keys))) {
+ expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
+ expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
+ }
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx
new file mode 100644
index 00000000..d07a2545
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx
@@ -0,0 +1,436 @@
+/**
+ * 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§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, afterEach, vi } 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';
+import {
+ createLayoutTest,
+ createMockComponentRegistryWithBasics,
+} from '@core/template-engine/__tests__/utils/layoutTestUtils';
+
+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, 'connection_tab_panel');
+ expect(container).toBeTruthy();
+ expect((container as { dataKey?: string }).dataKey).toBe('form');
+ expect((container as { trackChanges?: boolean }).trackChanges).toBe(true);
+ });
+});
+
+describe('plugin_settings.json — 섹션', () => {
+ it.each([
+ ['preparation_notice', '사용 전 준비 안내(상단)'],
+ ['section_api', 'API 연동'],
+ ['section_sending', '발송 설정'],
+ ['report_section', '리포트 수신 설정'],
+ ])('%s 섹션이 존재한다', (id) => {
+ expect(findById(root, id)).toBeTruthy();
+ });
+
+ it('준비 안내 박스는 총괄 안내(intro)와 콘솔 링크를 구분선 위에 먼저 노출한다', () => {
+ const notice = findById(root, 'preparation_notice');
+ const raw = JSON.stringify(notice);
+ // 총괄 안내 문구 키 + 콘솔 링크가 존재한다
+ expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.intro');
+ expect(raw).toContain('sirsoft-message_bizppurio.settings.preparation.console_link');
+ // 총괄 안내가 채널별 준비 목록(sms_label)보다 먼저 배치된다 (이미지1 구조)
+ const introIdx = raw.indexOf('preparation.intro');
+ const smsIdx = raw.indexOf('preparation.sms_label');
+ expect(introIdx).toBeGreaterThanOrEqual(0);
+ expect(smsIdx).toBeGreaterThanOrEqual(0);
+ expect(introIdx).toBeLessThan(smsIdx);
+ });
+
+ it('채널별 준비 목록은 PC(lg 이상) 2단, 모바일 1단 그리드로 배치된다', () => {
+ // 문자·카카오 블록을 감싼 컨테이너가 grid grid-cols-1 lg:grid-cols-2 여야 한다.
+ // sms_label 을 담은 블록의 부모(구분선 아래 컨테이너)에서 grid 클래스를 확인.
+ const notice = findById(root, 'preparation_notice');
+ const raw = JSON.stringify(notice);
+ // 세로 1단 고정(flex-col) 이 아니라 반응형 grid 여야 한다.
+ expect(raw).toContain('grid-cols-1');
+ expect(raw).toContain('lg:grid-cols-2');
+ });
+
+ it('문자·카카오 준비 블록은 카드(배경+테두리)로 감싸고 채널 아이콘을 라벨에 붙인다', () => {
+ const notice = findById(root, 'preparation_notice');
+ const raw = JSON.stringify(notice);
+ // 각 열이 카드로 감싸짐 (다크모드 쌍 포함 solid 배경)
+ expect(raw).toContain('bg-blue-100');
+ expect(raw).toContain('dark:bg-blue-900');
+ // 채널별 아이콘 (문자=envelope, 카카오=comment-dots)
+ expect(raw).toContain('"envelope"');
+ expect(raw).toContain('"comment-dots"');
+ });
+
+ it('검수 모드 카드에 is_test_mode Toggle 이 있다', () => {
+ const card = findById(root, 'test_mode_card');
+ expect(card).toBeTruthy();
+ const raw = JSON.stringify(card);
+ expect(raw).toContain('"Toggle"');
+ expect(raw).toContain('is_test_mode');
+ });
+
+ it('검수 모드 카드에 검수/운영 계정 분리 권장 안내가 상시 노출된다', () => {
+ // 검수 켜짐/꺼짐과 무관하게(if 조건 없이) 카드 안에 계정 분리 권장 문구가 있어야 한다.
+ const card = findById(root, 'test_mode_card') as { if?: string } | null;
+ expect(card).toBeTruthy();
+ expect(card?.if).toBeUndefined(); // 카드 자체가 조건부가 아님 → 상시 노출
+ const raw = JSON.stringify(card);
+ expect(raw).toContain('settings.test_mode.account_notice');
+ });
+
+ it('운영 모드(검수 off) 경고 박스가 조건부로 존재한다', () => {
+ const warning = findById(root, 'live_mode_warning');
+ expect(warning).toBeTruthy();
+ expect((warning as { if?: string }).if).toContain('!_local.form.is_test_mode');
+ });
+});
+
+describe('plugin_settings.json — 입력 필드 6종', () => {
+ it.each([
+ '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');
+ }
+ });
+
+});
+
+describe('plugin_settings.json — 연결 확인 (§529)', () => {
+ it('field_connection_check 필드가 field_password 와 별개 노드로 존재한다', () => {
+ const field = findById(root, 'field_connection_check');
+ expect(field).toBeTruthy();
+ expect(field).not.toBe(findById(root, 'field_password'));
+ });
+
+ it('연결 확인 버튼이 캐시 초기화 버튼과 동일한 grid-cols-12(4/8) + 우측 정렬 패턴을 따른다', () => {
+ const field = findById(root, 'field_connection_check');
+ const raw = JSON.stringify(field);
+ expect(raw).toContain('grid-cols-1');
+ expect(raw).toContain('lg:grid-cols-12');
+ expect(raw).toContain('lg:col-span-4');
+ expect(raw).toContain('lg:col-span-8');
+ expect(raw).toContain('lg:justify-end');
+ });
+
+ it('버튼은 대기 상태 plug, 로딩 상태 spinner(animate-spin) 아이콘을 조건부로 갖는다', () => {
+ const btn = findById(root, 'connection_check_button');
+ const raw = JSON.stringify(btn);
+ expect(raw).toContain('"plug"');
+ expect(raw).toContain('"spinner"');
+ expect(raw).toContain('animate-spin');
+ expect(raw).toContain('_local.tokenChecking');
+ });
+
+ it('버튼 클릭은 hasChanges=false 일 때만 apiCall 로 /admin/token/check 를 POST 한다', () => {
+ const btn = findById(root, 'connection_check_button');
+ const raw = JSON.stringify(btn);
+ expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/token/check');
+ expect(raw).toContain('"method":"POST"');
+ // apiCall 액션 자체가 !hasChanges 가드를 갖는다
+ const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? [];
+ const sequence = actions.find((a) => a.handler === 'sequence');
+ const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[];
+ const apiCallAction = inner.find((a) => a.handler === 'apiCall');
+ expect(apiCallAction?.if).toContain('!_local.hasChanges');
+ });
+
+ it('hasChanges=true 일 때는 apiCall 없이 unsaved_changes toast 만 실행한다', () => {
+ const btn = findById(root, 'connection_check_button');
+ const actions = (btn as { actions?: AnyNode[] } | null)?.actions ?? [];
+ const sequence = actions.find((a) => a.handler === 'sequence');
+ const inner = ((sequence as { actions?: AnyNode[] } | undefined)?.actions ?? []) as AnyNode[];
+ const guardToast = inner.find((a) => a.handler === 'toast' && a.if === '{{_local.hasChanges}}');
+ expect(guardToast).toBeTruthy();
+ expect(JSON.stringify(guardToast)).toContain('connection_check.unsaved_changes');
+ });
+
+ it('성공/실패 결과는 화면 상시 표시 없이 toast 로만 안내한다', () => {
+ const btn = findById(root, 'connection_check_button');
+ const raw = JSON.stringify(btn);
+ expect(raw).toContain('connection_check.success');
+ expect(raw).toContain('connection_check.failed');
+ });
+});
+
+/**
+ * hasChanges 초기값(런타임) 검증 — §529 비판적 재검토에서 발견한 공백.
+ *
+ * 위 describe 블록은 레이아웃 JSON의 if 조건 문자열만 정적으로 확인한다. 하지만
+ * "페이지를 막 열고 아무것도 바꾸지 않은 상태(_local.hasChanges 가 아직 세팅 전)"
+ * 에서 실제로 어떻게 평가되는지는 런타임 값 — Boolean(undefined) 규칙에 따라
+ * `{{!_local.hasChanges}}` 는 true(API 호출 진행), `{{_local.hasChanges}}` 는
+ * false(경고 미노출) 가 되어야 정상이다. 실제 엔진(createLayoutTest)으로 렌더해
+ * 이 가정을 증명한다(추정이 아니라 확인).
+ */
+describe('plugin_settings.json — 연결 확인 버튼의 hasChanges 초기 상태 (§529 런타임 검증)', () => {
+ const connectionCheckButton = findById(root, 'connection_check_button') as AnyNode & {
+ actions: AnyNode[];
+ };
+ const clickAction = connectionCheckButton.actions.find((a) => a.handler === 'sequence') as AnyNode;
+
+ function buildProbe() {
+ return {
+ version: '1.0.0',
+ layout_name: 'test/connection-check-initial-state',
+ components: [connectionCheckButton],
+ };
+ }
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ /**
+ * toast 핸들러는 ActionDispatcher 내장 처리(handleToast)라 커스텀 registerHandler
+ * 로 가로챌 수 없다 — 실제로는 globalStateUpdater 를 통해 `_global.toasts` 배열에
+ * 쌓인다(createLayoutTest 의 getToasts() 는 이 경로를 타지 않는 죽은 유틸리티임을
+ * 최소 재현으로 확인). 따라서 getState()._global.toasts 를 직접 읽는다.
+ */
+ function lastToastMessages(utils: ReturnType): string[] {
+ const toasts = (utils.getState()._global?.toasts ?? []) as Array<{ message: string }>;
+ return toasts.map((t) => t.message);
+ }
+
+ it('로드 직후(hasChanges 미설정) 클릭하면 hasChanges 가드를 통과해 실제 API 응답까지 도달한다', async () => {
+ const registry = createMockComponentRegistryWithBasics();
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ utils.mockApi('token_check_probe', { response: { success: true } });
+ await utils.render();
+
+ // 초기 상태: hasChanges 는 아직 세팅되지 않음(undefined) — 저장 폼을 만지지 않은 상태.
+ // Boolean(undefined) === false 이므로 미저장 가드({{_local.hasChanges}})는 통과해야 한다.
+ expect(utils.getState()._local?.hasChanges).toBeFalsy();
+
+ await utils.triggerAction(clickAction);
+
+ // hasChanges 가드를 통과했다면 apiCall 이 실행되어 onSuccess/onError 중
+ // 하나가 반드시 toast 를 남긴다 — unsaved_changes 경고는 뜨지 않고,
+ // success 또는 failed(원격 호출 실패 응답 처리) 중 하나만 떠야 한다.
+ const messages = lastToastMessages(utils);
+ expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(false);
+ expect(messages.length).toBeGreaterThan(0);
+
+ utils.cleanup();
+ });
+
+ it('hasChanges=true 로 세팅된 뒤 클릭하면 API 호출 없이 미저장 경고 toast 만 뜬다', async () => {
+ const registry = createMockComponentRegistryWithBasics();
+ const utils = createLayoutTest(buildProbe(), { componentRegistry: registry as any, locale: 'ko' });
+ await utils.render();
+
+ utils.setState('hasChanges', true, 'local');
+ await utils.triggerAction(clickAction);
+
+ const messages = lastToastMessages(utils);
+ expect(messages.some((m) => m.includes('connection_check.unsaved_changes'))).toBe(true);
+ expect(messages.some((m) => m.includes('connection_check.success'))).toBe(false);
+
+ utils.cleanup();
+ });
+});
+
+describe('plugin_settings.json — 비밀번호 필드 라벨 (§529)', () => {
+ it('비밀번호 필드 라벨이 "비즈뿌리오 모듈 비밀번호"로 G7 로그인 비밀번호와 구분된다', () => {
+ expect((ko as Record).settings.fields.password.label).toBe('비즈뿌리오 모듈 비밀번호');
+ expect((en as Record).settings.fields.password.label).toBe('Bizppurio Module Password');
+ });
+});
+
+describe('plugin_settings.json — 리포트 수신 설정', () => {
+ it('report_url 데이터소스가 조회 엔드포인트를 호출한다', () => {
+ const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
+ const reportUrl = sources.find((s) => s.id === 'report_url');
+ expect(reportUrl).toBeTruthy();
+ expect(reportUrl?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/report-url');
+ });
+
+ it('리포트 섹션에 조회값(fallback 웹훅 경로) readonly 표시 + 복사 버튼이 있다', () => {
+ const section = findById(root, 'report_section');
+ const raw = JSON.stringify(section);
+ expect(raw).toContain('report_url?.data?.url');
+ expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/webhook');
+ expect(raw).toContain('"readOnly":true');
+ expect(raw).toContain('copyToClipboard');
+ });
+});
+
+describe('plugin_settings.json — 필드 인라인 에러', () => {
+ it.each([
+ 'bizppurio_id',
+ 'password',
+ 'api_key',
+ 'sender_number',
+ 'sender_key',
+ ])('%s 필드에 인라인 에러 노드가 존재한다', (name) => {
+ const errorNode = findById(root, `field_${name}_error`);
+ expect(errorNode).toBeTruthy();
+ expect((errorNode as { if?: string }).if).toContain(`_local.errors?.${name}`);
+ });
+});
+
+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', 'switch',
+ 'refetchDataSource', 'scrollIntoView', 'copyToClipboard',
+ 'openModal', 'closeModal', 'replaceUrl',
+ 'sirsoft-message_bizppurio.uploadTemplateImage',
+ ];
+ 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, path: string): unknown =>
+ path.split('.').reduce((acc, seg) => {
+ if (acc && typeof acc === 'object') {
+ return (acc as Record)[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();
+ }
+ });
+});
+
+describe('plugin_settings.json — 탭 전환', () => {
+ // 탭 버튼은 query.tab 을 바꾸며 화면(if 조건부 패널)을 다시 그려야 하므로
+ // replaceUrl(URL만 변경, if 재평가 없음) 이 아니라 navigate 를 써야 한다.
+ const tabButtonHandlers = (id: string): string[] => {
+ const btn = findById(layout, id);
+ const handlers: string[] = [];
+ const walk = (node: unknown): void => {
+ if (!node || typeof node !== 'object') return;
+ const n = node as Record;
+ if (typeof n.handler === 'string') handlers.push(n.handler);
+ for (const v of Object.values(n)) {
+ if (Array.isArray(v)) v.forEach(walk);
+ else if (v && typeof v === 'object') walk(v);
+ }
+ };
+ walk((btn as { actions?: unknown })?.actions);
+ return handlers;
+ };
+
+ it('환경설정 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => {
+ const handlers = tabButtonHandlers('tab_connection');
+ expect(handlers).toContain('navigate');
+ expect(handlers).not.toContain('replaceUrl');
+ });
+
+ it('알림톡 템플릿 탭 버튼은 navigate 로 화면을 전환한다 (replaceUrl 금지)', () => {
+ const handlers = tabButtonHandlers('tab_templates');
+ expect(handlers).toContain('navigate');
+ expect(handlers).not.toContain('replaceUrl');
+ });
+});
+
+describe('plugin_settings.json — 목록 조회 실패 표시', () => {
+ it('alimtalk_templates 데이터소스가 실패 사유를 _local.templateListError 에 담는다', () => {
+ const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
+ const ds = sources.find((s) => s.id === 'alimtalk_templates') as
+ | Record
+ | undefined;
+ expect(ds).toBeTruthy();
+ const onError = JSON.stringify(ds?.onError ?? {});
+ // 카카오가 준 사유(kakao_message)를 우선 노출, 없으면 error.message 폴백
+ expect(onError).toContain('templateListError');
+ expect(onError).toContain('kakao_message');
+ });
+
+ it('목록 오류 배너가 오류 존재 + 준비완료(ready) 조건으로 존재한다', () => {
+ const banner = findById(layout, 'templates_list_error') as
+ | Record
+ | undefined;
+ expect(banner).toBeTruthy();
+ // 키 미설정(ready=false)일 때는 readiness 안내 배너가 담당 → 빨간 오류 배너는 숨겨
+ // 두 배너가 동시에 뜨지 않게 한다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우만 노출.
+ expect(banner?.if).toBe('{{_local.templateListError && templates_readiness?.data?.ready}}');
+ // 사유 본문(카카오 실제 사유)을 그대로 렌더한다
+ expect(JSON.stringify(banner)).toContain('{{_local.templateListError}}');
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts
new file mode 100644
index 00000000..5eeecb61
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/setup.ts
@@ -0,0 +1,25 @@
+/**
+ * 비즈뿌리오 플러그인 레이아웃 렌더 테스트 환경 설정.
+ *
+ * jest-dom matcher(toBeInTheDocument 등)를 로드하고, jsdom 전역 mock 을 준비한다.
+ * 코어 레이아웃 렌더 테스트(createLayoutTest)를 사용하는 테스트가 소비한다.
+ */
+
+import '@testing-library/jest-dom';
+import { vi } from 'vitest';
+
+if (typeof window !== 'undefined') {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts
new file mode 100644
index 00000000..ffbf6891
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts
@@ -0,0 +1,11 @@
+/**
+ * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.
+ *
+ * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인
+ * 식별자가 네임스페이스로 접두된다.
+ *
+ * 현재 등록 핸들러 없음(알림톡 템플릿 등록·이미지 업로드 제거로 uploadTemplateImage 제거).
+ * 커스텀 핸들러 추가 시 이 맵에 등록한다.
+ */
+
+export const handlerMap = {} as const;
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts
new file mode 100644
index 00000000..9bff1ba5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/index.ts
@@ -0,0 +1,95 @@
+/**
+ * sirsoft-message_bizppurio 플러그인 엔트리포인트.
+ *
+ * 플러그인 활성화 시 자동 로드되어 handlerMap 의 커스텀 핸들러를 ActionDispatcher 에
+ * 등록한다. 핸들러명은 `sirsoft-message_bizppurio.{name}` 네임스페이스를 갖는다.
+ * (현재 등록 핸들러 없음 — 필요 시 handlers/index.ts 에 추가.)
+ */
+
+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,
+ };
+}
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts
new file mode 100644
index 00000000..a0610d1a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/types.ts
@@ -0,0 +1,28 @@
+/**
+ * sirsoft-message_bizppurio 플러그인 프론트 타입 정의.
+ */
+
+/**
+ * 액션 컨텍스트 인터페이스.
+ *
+ * ActionDispatcher 가 커스텀 핸들러 실행 시 전달하는 컨텍스트다.
+ */
+export interface ActionContext {
+ /** 현재 로컬 상태 가져오기 */
+ getLocalState?: () => Record;
+ /** 로컬 상태 업데이트 */
+ setLocalState?: (updates: Record) => void;
+ /** 이벤트 객체 */
+ event?: Event;
+ /** 데이터 컨텍스트 */
+ dataContext?: Record;
+}
+
+/**
+ * 커스텀 핸들러 액션 객체(공통).
+ */
+export interface ActionWithParams {
+ handler: string;
+ params?: Record;
+ [key: string]: any;
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json
new file mode 100644
index 00000000..a53bb8b7
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json
@@ -0,0 +1,227 @@
+{
+ "name": "Bizppurio Messaging",
+ "description": "Bizppurio 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.",
+ "test_mode": {
+ "label": "Inspection Mode",
+ "hint": "In inspection mode, messages are sent to the Bizppurio inspection domain. Turn it off to send through the production domain.",
+ "account_notice": "We recommend using separate Bizppurio accounts for inspection and production."
+ },
+ "live_mode_warning_title": "Sending in production",
+ "live_mode_warning_body": "Inspection mode is off, so real SMS and alimtalk messages are sent to actual customers and sending fees are charged. Keep inspection mode on during inspection.",
+ "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."
+ }
+ },
+ "fields": {
+ "bizppurio_id": {
+ "label": "Bizppurio ID",
+ "hint": "The Bizppurio account ID used for both sending and Kakao management."
+ },
+ "password": {
+ "label": "Bizppurio Module Password",
+ "hint": "Please enter the Bizppurio module password. (Bizppurio console > Module Integration Settings > Change Module Password)"
+ },
+ "connection_check": {
+ "label": "Connection Check",
+ "hint": "Click to instantly verify the saved ID and password. (Only reflects saved values)",
+ "button": "Check Connection",
+ "checking": "Checking...",
+ "success": "Authentication verified successfully. The ID and password are correct.",
+ "failed": "Failed to verify authentication.",
+ "unsaved_changes": "Please save your changes first."
+ },
+ "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."
+ },
+ "template_cache_minutes": {
+ "label": "Alimtalk content cache (minutes)",
+ "hint": "Reuses Kakao template content for this period. 0 = always latest (not recommended for high volume).",
+ "clear_cache": "Clear cache",
+ "clear_cache_hint": "If you edited a template in Kakao, click to apply it immediately.",
+ "clear_cache_success": "Template content cache cleared. The latest content will apply from the next dispatch.",
+ "clear_cache_failed": "Failed to clear the cache."
+ }
+ },
+ "report": {
+ "section_title": "Report Endpoint",
+ "hint": "Bizppurio can push each SMS/alimtalk delivery result (success/failure) to this address (URL PUSH). Register it with the Bizppurio business team (or the report settings in the console).",
+ "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."
+ },
+ "cache": {
+ "section_title": "Alimtalk dispatch content cache",
+ "section_description": "When sending alimtalk, the template content (body and buttons) registered in Kakao must be sent as-is. To avoid requesting the content from Kakao on every dispatch, the fetched content is reused (cached) for a set period. This keeps sending fast and avoids hitting Kakao's lookup rate limit even under high volume."
+ },
+ "tabs": {
+ "connection": "Settings",
+ "templates": "Alimtalk Templates"
+ },
+ "preparation": {
+ "intro": "To send SMS or Kakao Alimtalk messages, first complete the preparations below in the Bizppurio console.",
+ "sms_label": "SMS/LMS",
+ "sms_sender": "Register your sender number.",
+ "kakao_label": "Kakao Alimtalk",
+ "kakao_channel": "Create a KakaoTalk business channel and register a sender profile.",
+ "kakao_template": "Register your Alimtalk templates and get them approved.",
+ "kakao_apikey": "Request an API key from Bizppurio support.",
+ "console_link": "Open Bizppurio console"
+ }
+ },
+ "binding": {
+ "section_title": "KakaoTalk Alimtalk Binding",
+ "list_guide": "Link a KakaoTalk alimtalk template to each notification. Use [Connect] to assign an approved template — the alimtalk message is then sent automatically when that event occurs.",
+ "section_hint": "Only approved alimtalk templates can be linked. Saving applies to this notification immediately.",
+ "modal_title": "Alimtalk Binding · {name}",
+ "unbound": "Not connected",
+ "unavailable": "Unavailable — reconnect needed",
+ "btn_connect": "Connect",
+ "btn_change": "Change",
+ "fallback_on": "SMS fallback ON",
+ "fallback_off": "SMS fallback OFF",
+ "connected_template": "Connected Template",
+ "none": "Not connected",
+ "no_approved_templates": "No approved (sendable) alimtalk templates. Register and inspect a template first.",
+ "templates_load_failed": "Failed to load the template list. Check the plugin settings (credentials).",
+ "fallback_sms": "Fall back to SMS on failure",
+ "fallback_hint": "If the alimtalk message fails, this notification's body is sent as an SMS instead.",
+ "variables_hint": "Available variables (auto-substituted on send)",
+ "saved": "Alimtalk binding saved.",
+ "save_error": "Failed to save alimtalk binding."
+ },
+ "banner": {
+ "not_ready": "Setup required for delivery is incomplete.",
+ "setup_action": "Set up",
+ "test_mode": "Inspection mode — messages are not actually sent."
+ },
+ "dispatch_result": {
+ "column_header": "SMS/Alimtalk Result",
+ "inspection_label": "Inspection",
+ "low_balance": "Low balance",
+ "fallback": "SMS fallback {status}",
+ "detail_title": "SMS/Alimtalk delivery result",
+ "channel_label": "Channel: {channel}",
+ "sent_content_label": "Actual sent content",
+ "sent_content_hint": "Alimtalk messages are sent using the actual content of the approved Kakao template, which may differ from the \"Body\" above."
+ },
+ "editor": {
+ "data_source": {
+ "dispatch_results": "Bizppurio dispatch results"
+ }
+ },
+ "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 inspection mode and using templates approved by Kakao."
+ },
+ "list_error": {
+ "title": "Failed to load the template list"
+ },
+ "list_notice": {
+ "console_desc": "Register, edit, and review templates in the Bizppurio console.",
+ "console_link": "Open Bizppurio console"
+ },
+ "list": {
+ "refresh": "Refresh",
+ "search": "Search",
+ "search_placeholder": "Search by name (2-50 chars)",
+ "filter_all": "All statuses",
+ "empty": "No alimtalk templates registered.",
+ "empty_hint": "Templates registered in the Bizppurio console appear here.",
+ "load_failed": "Failed to load templates. Check the sender profile key and API key.",
+ "columns": {
+ "no": "No.",
+ "name": "Name",
+ "code": "Code",
+ "status": "Status",
+ "requested_at": "Requested",
+ "processed_at": "Processed",
+ "actions": "Content"
+ }
+ },
+ "status": {
+ "sendable": "Sendable",
+ "inspecting": "Inspecting",
+ "rejected": "Rejected",
+ "uninspected": "Not inspected",
+ "stopped": "Stopped",
+ "blocked": "Blocked",
+ "dormant": "Dormant",
+ "unknown": "Unknown"
+ },
+ "status_sub": {
+ "rdy": "(unused)"
+ },
+ "status_guide": {
+ "title": "Status badges",
+ "sendable_label": "Sendable",
+ "sendable": "Approved. Only this status can be linked to notifications and sent.",
+ "inspecting_label": "Inspecting",
+ "inspecting": "Kakao review in progress (2-3 business days).",
+ "pending_label": "Uninspected / Rejected",
+ "pending": "Cannot be sent yet. Request review or edit in the console."
+ },
+ "link_type": {
+ "WL": "Web link",
+ "AL": "App link",
+ "DS": "Delivery tracking",
+ "BK": "Bot keyword",
+ "MD": "Message delivery",
+ "AC": "Add channel",
+ "BC": "Consultation talk",
+ "BT": "Bot transfer",
+ "TN": "Call",
+ "MP": "Map",
+ "P1": "Secure image send",
+ "P2": "Privacy consent",
+ "P3": "One-click pay"
+ },
+ "actions": {
+ "detail": "Detail"
+ },
+ "detail": {
+ "title": "Template detail",
+ "close": "Close",
+ "buttons": "Buttons",
+ "extra": "Additional info",
+ "category": "Category",
+ "code": "Template code",
+ "content": "Template content",
+ "emphasize_type": "Template type",
+ "image_upload": "Attach image",
+ "subtitle_field": "Highlight subtitle",
+ "title_field": "Highlight title",
+ "type_image": "Image",
+ "type_none": "Basic",
+ "type_text": "Highlighted"
+ }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json
new file mode 100644
index 00000000..9240029c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json
@@ -0,0 +1,227 @@
+{
+ "name": "비즈뿌리오 메시지 발송",
+ "description": "비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송 플러그인입니다.",
+ "settings": {
+ "title": "비즈뿌리오 메시지 발송 설정",
+ "description": "비즈뿌리오 연동 정보와 발송 설정을 관리합니다.",
+ "save": "저장",
+ "saving": "저장 중...",
+ "save_success": "설정이 저장되었습니다.",
+ "save_failed": "설정 저장에 실패했습니다.",
+ "test_mode": {
+ "label": "검수 모드",
+ "hint": "검수 모드에서는 비즈뿌리오 검수 도메인으로 발송됩니다. 끄면 운영 도메인으로 실제 발송됩니다.",
+ "account_notice": "검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다."
+ },
+ "live_mode_warning_title": "운영 환경으로 발송됩니다",
+ "live_mode_warning_body": "검수 모드가 꺼져 있어 실제 고객에게 문자·알림톡이 발송되고 발송 비용이 청구됩니다. 검수 단계에서는 검수 모드를 켜 두세요.",
+ "sections": {
+ "api": {
+ "title": "API 연동",
+ "description": "발송 시스템과 카카오 관리 시스템에 공통으로 사용하는 연동 정보입니다."
+ },
+ "sending": {
+ "title": "발송 설정",
+ "description": "문자·알림톡 발송에 사용하는 발신 정보입니다."
+ }
+ },
+ "fields": {
+ "bizppurio_id": {
+ "label": "비즈뿌리오 아이디",
+ "hint": "발송·카카오 관리에 공통으로 사용하는 비즈뿌리오 아이디입니다."
+ },
+ "password": {
+ "label": "비즈뿌리오 모듈 비밀번호",
+ "hint": "비즈뿌리오 모듈 비밀번호를 입력해주세요. (비즈뿌리오 콘솔 > 모듈연동 환경설정 > 모듈 비밀번호 변경)"
+ },
+ "connection_check": {
+ "label": "연결 확인",
+ "hint": "저장된 아이디·비밀번호가 유효한지 눌러서 바로 확인하세요. (저장 후에만 반영됩니다)",
+ "button": "연결 확인",
+ "checking": "확인 중...",
+ "success": "인증이 정상적으로 확인되었습니다. 아이디와 비밀번호가 올바릅니다.",
+ "failed": "인증 확인에 실패했습니다.",
+ "unsaved_changes": "변경사항을 먼저 저장해주세요."
+ },
+ "api_key": {
+ "label": "API 키",
+ "hint": "API 키는 비즈뿌리오 고객센터로 아이디와 함께 접수하면 확인 후 발급됩니다."
+ },
+ "sender_number": {
+ "label": "발신번호",
+ "hint": "문자·알림톡 발송에 사용하는 발신 전화번호입니다."
+ },
+ "sender_key": {
+ "label": "알림톡 발신프로필 키",
+ "hint": "알림톡 발송·템플릿 조회에 사용하는 발신프로필 키(40자)입니다."
+ },
+ "template_cache_minutes": {
+ "label": "알림톡 내용 캐시 시간(분)",
+ "hint": "카카오 템플릿 내용을 이 시간만큼 재사용합니다. 0 = 매번 최신(발송 많으면 비권장).",
+ "clear_cache": "캐시 초기화",
+ "clear_cache_hint": "카카오에서 템플릿을 수정했다면 눌러서 즉시 반영하세요.",
+ "clear_cache_success": "템플릿 내용 캐시를 초기화했습니다. 다음 발송부터 최신 내용이 반영됩니다.",
+ "clear_cache_failed": "캐시 초기화에 실패했습니다."
+ }
+ },
+ "report": {
+ "section_title": "리포트 수신 설정",
+ "hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.",
+ "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.",
+ "copy": "복사",
+ "copied": "리포트 수신 주소가 클립보드에 복사되었습니다."
+ },
+ "cache": {
+ "section_title": "알림톡 발송 내용 캐시",
+ "section_description": "알림톡은 발송할 때 카카오에 등록된 템플릿 내용(본문·버튼)을 그대로 보내야 합니다. 발송할 때마다 카카오에 내용을 요청하지 않도록, 한 번 가져온 내용을 일정 시간 재사용(캐시)합니다. 덕분에 발송이 많아도 카카오 조회 제한에 걸리지 않고 빠르게 발송됩니다."
+ },
+ "tabs": {
+ "connection": "환경설정",
+ "templates": "알림톡 템플릿"
+ },
+ "preparation": {
+ "intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.",
+ "sms_label": "문자(SMS/LMS)",
+ "sms_sender": "발신번호를 등록하세요.",
+ "kakao_label": "카카오 알림톡",
+ "kakao_channel": "카카오톡 비즈니스 채널을 만들고 발신프로필을 등록하세요.",
+ "kakao_template": "알림톡 템플릿을 등록하고 승인받으세요.",
+ "kakao_apikey": "고객센터에 API 키를 요청하세요.",
+ "console_link": "비즈뿌리오 콘솔 열기"
+ }
+ },
+ "binding": {
+ "section_title": "카카오 알림톡 연동",
+ "list_guide": "각 알림에 발송할 카카오 알림톡 템플릿을 연결하세요. [연결]로 승인된 템플릿을 지정하면 해당 이벤트 발생 시 알림톡이 자동 발송됩니다.",
+ "section_hint": "승인된 알림톡 템플릿만 연결할 수 있습니다. 저장하면 이 알림에 즉시 반영됩니다.",
+ "modal_title": "알림톡 연결 · {name}",
+ "unbound": "미연결",
+ "unavailable": "사용 불가 — 재연결 필요",
+ "btn_connect": "연결",
+ "btn_change": "연결 변경",
+ "fallback_on": "SMS 대체 ON",
+ "fallback_off": "SMS 대체 OFF",
+ "connected_template": "연결 템플릿",
+ "none": "연결 안 함",
+ "no_approved_templates": "발송 가능한(승인된) 알림톡 템플릿이 없습니다. 먼저 템플릿을 등록·검수하세요.",
+ "templates_load_failed": "템플릿 목록을 불러오지 못했습니다. 플러그인 설정(자격증명)을 확인하세요.",
+ "fallback_sms": "실패 시 SMS로 대체발송",
+ "fallback_hint": "알림톡 발송이 실패하면 이 알림의 본문 내용이 문자(SMS)로 대신 발송됩니다.",
+ "variables_hint": "제공 변수 (발송 시 자동 치환)",
+ "saved": "알림톡 연동을 저장했습니다.",
+ "save_error": "알림톡 연동 저장에 실패했습니다."
+ },
+ "banner": {
+ "not_ready": "발송에 필요한 설정이 완료되지 않았습니다.",
+ "setup_action": "설정하기",
+ "test_mode": "검수 모드입니다 — 실제 발송은 이뤄지지 않습니다."
+ },
+ "dispatch_result": {
+ "column_header": "문자·알림톡 결과",
+ "inspection_label": "검수",
+ "low_balance": "잔액 부족",
+ "fallback": "SMS 대체발송 {status}",
+ "detail_title": "문자·알림톡 발송 결과",
+ "channel_label": "발송 채널: {channel}",
+ "sent_content_label": "실제 발송 내용",
+ "sent_content_hint": "알림톡은 카카오 승인 템플릿의 실제 내용으로 발송되며, 위 \"본문\"과 다를 수 있습니다."
+ },
+ "editor": {
+ "data_source": {
+ "dispatch_results": "비즈뿌리오 발송 결과"
+ }
+ },
+ "templates": {
+ "title": "알림톡 템플릿",
+ "description": "카카오 알림톡 템플릿을 등록·검수·관리합니다. 승인된 템플릿만 알림 연동에 사용할 수 있습니다.",
+ "readiness": {
+ "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다",
+ "go_settings": "환경설정으로 이동",
+ "missing_label": "미설정 항목",
+ "api_key_missing": "카카오 관리 API 키",
+ "sender_key_missing": "알림톡 발신프로필 키",
+ "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 조회·등록이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 카카오 승인을 받은 템플릿만 사용할 수 있습니다."
+ },
+ "list_error": {
+ "title": "템플릿 목록을 불러오지 못했습니다"
+ },
+ "list_notice": {
+ "console_desc": "템플릿 등록·수정·검수는 비즈뿌리오 콘솔에서 진행해주세요.",
+ "console_link": "비즈뿌리오 콘솔 열기"
+ },
+ "list": {
+ "refresh": "새로고침",
+ "search": "검색",
+ "search_placeholder": "템플릿명 검색 (2~50자)",
+ "filter_all": "전체 상태",
+ "empty": "등록된 알림톡 템플릿이 없습니다.",
+ "empty_hint": "비즈뿌리오 콘솔에서 등록한 템플릿이 여기에 표시됩니다.",
+ "load_failed": "템플릿 목록을 불러오지 못했습니다. 발신프로필 키와 API 키를 확인하세요.",
+ "columns": {
+ "no": "번호",
+ "name": "템플릿명",
+ "code": "코드",
+ "status": "상태",
+ "requested_at": "등록요청일",
+ "processed_at": "처리일",
+ "actions": "내용"
+ }
+ },
+ "status": {
+ "sendable": "발송가능",
+ "inspecting": "검수중",
+ "rejected": "반려",
+ "uninspected": "미검수",
+ "stopped": "중지",
+ "blocked": "차단",
+ "dormant": "휴면",
+ "unknown": "알 수 없음"
+ },
+ "status_sub": {
+ "rdy": "(사용전)"
+ },
+ "status_guide": {
+ "title": "상태 배지",
+ "sendable_label": "발송가능",
+ "sendable": "승인 완료. 이 상태만 알림에 연결·발송할 수 있습니다.",
+ "inspecting_label": "검수중",
+ "inspecting": "카카오 검수 진행 중 (영업일 2~3일).",
+ "pending_label": "미검수·반려",
+ "pending": "아직 발송할 수 없습니다. 콘솔에서 검수요청·수정하세요."
+ },
+ "link_type": {
+ "WL": "웹링크",
+ "AL": "앱링크",
+ "DS": "배송조회",
+ "BK": "봇키워드",
+ "MD": "메시지전달",
+ "AC": "채널추가",
+ "BC": "상담톡전환",
+ "BT": "봇전환",
+ "TN": "전화하기",
+ "MP": "지도보기",
+ "P1": "이미지 보안전송",
+ "P2": "개인정보이용",
+ "P3": "원클릭 결제"
+ },
+ "actions": {
+ "detail": "상세"
+ },
+ "detail": {
+ "title": "템플릿 상세",
+ "close": "닫기",
+ "buttons": "버튼",
+ "extra": "부가정보",
+ "category": "카테고리",
+ "code": "템플릿 코드",
+ "content": "템플릿 내용",
+ "emphasize_type": "템플릿 유형",
+ "image_upload": "이미지 첨부",
+ "subtitle_field": "보조 문구",
+ "title_field": "강조표기 문구",
+ "type_image": "이미지형",
+ "type_none": "기본형",
+ "type_text": "강조표기형"
+ }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json
new file mode 100644
index 00000000..7753594a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json
@@ -0,0 +1,1863 @@
+{
+ "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",
+ "_e2e_allow": "e2e:allow (§529) 연결 확인 필드·hasChanges 가드 추가는 레이아웃 회귀테스트(plugin_settings.test.tsx) + PHPUnit Feature(TokenCheckEndpointTest·InvalidateTokenOnSettingsSaveListenerTest)로 커버. 실연동 Playwright는 계획서 §9 방침에 따라 PO 직접 수행(인증·발송 환경 필요) 단계에서 도입 예정 — tests/scenarios/plugin-settings.yaml 상단 audit:allow 사유 참고."
+ },
+ "init_actions": [
+ {
+ "if": "{{(query.tab ?? 'connection') === 'templates'}}",
+ "handler": "refetchDataSource",
+ "params": { "dataSourceId": "alimtalk_templates" }
+ }
+ ],
+ "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
+ },
+ {
+ "id": "report_url",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/report-url",
+ "method": "GET",
+ "auto_fetch": true,
+ "auth_required": true,
+ "initLocal": "reportInfo",
+ "if": "{{(query.tab ?? 'connection') === 'connection'}}"
+ },
+ {
+ "id": "alimtalk_templates",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "params": {
+ "status": "{{_local.templateStatus ?? ''}}",
+ "keyword": "{{_local.templateKeyword ?? ''}}",
+ "page": "{{_local.templatePage ?? 1}}"
+ },
+ "onSuccess": {
+ "handler": "setState",
+ "params": { "target": "local", "templateListError": null }
+ },
+ "onError": {
+ "handler": "setState",
+ "params": { "target": "local", "templateListError": "{{error.errors?.kakao_message ?? error.message}}" }
+ }
+ },
+ {
+ "id": "templates_readiness",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness",
+ "method": "GET",
+ "auto_fetch": true,
+ "auth_required": true,
+ "if": "{{(query.tab ?? 'connection') === 'templates'}}"
+ }
+ ],
+ "slots": {
+ "content": [
+ {
+ "id": "plugin_settings_page",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "admin-page-content-responsive"
+ },
+ "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": "settings_tabs",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "mt-6 border-b border-gray-200 dark:border-gray-700 flex gap-1" },
+ "children": [
+ {
+ "id": "tab_connection",
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "px-4 py-2.5 text-sm font-medium border-b-2 transition-colors {{(query.tab ?? 'connection') === 'connection' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "navigate",
+ "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": true }
+ }
+ ],
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.tabs.connection" }
+ ]
+ },
+ {
+ "id": "tab_templates",
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "px-4 py-2.5 text-sm font-medium border-b-2 transition-colors {{(query.tab ?? 'connection') === 'templates' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "actions": [
+ {
+ "handler": "navigate",
+ "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "templates" }, "mergeQuery": true, "replace": true }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": { "dataSourceId": "alimtalk_templates" }
+ }
+ ]
+ }
+ ],
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.tabs.templates" }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "connection_tab_panel",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(query.tab ?? 'connection') === 'connection'}}",
+ "dataKey": "form",
+ "trackChanges": true,
+ "children": [
+ {
+ "id": "preparation_notice",
+ "comment": "사용 전 준비 안내(통합 1박스) — 알림톡 템플릿 탭 list_notice 와 동일 구조. 위: 총괄 안내 1줄 + 콘솔 열기 링크. 구분선(border-t). 아래: 문자·카카오 채널별 사전 준비 목록(라벨 굵게 + 들여쓴 하위 불릿).",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "mt-6 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" },
+ "children": [
+ {
+ "comment": "위 — 총괄 안내 1줄 + 콘솔 열기 링크",
+ "type": "basic", "name": "Div",
+ "props": { "className": "space-y-1" },
+ "children": [
+ {
+ "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" },
+ "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.intro" }
+ ]
+ },
+ {
+ "type": "basic", "name": "A",
+ "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" },
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.console_link" },
+ { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "구분선 아래 — 문자·카카오 채널별 사전 준비 목록 (PC lg 이상 2단, 모바일 1단)",
+ "type": "basic", "name": "Div",
+ "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 grid grid-cols-1 lg:grid-cols-2 gap-4" },
+ "children": [
+ {
+ "comment": "문자(SMS/LMS) — 카드 + 아이콘 라벨 + 발신번호 등록 1줄",
+ "type": "basic", "name": "Div",
+ "props": { "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" },
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" }, "children": [ { "type": "basic", "name": "Icon", "props": { "name": "envelope", "size": "sm", "className": "text-blue-500 dark:text-blue-400" } }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_label" } ] },
+ { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.sms_sender" } ] }
+ ]
+ },
+ {
+ "comment": "카카오 알림톡 — 카드 + 아이콘 라벨 + 채널/템플릿/API키 3줄",
+ "type": "basic", "name": "Div",
+ "props": { "className": "space-y-2 rounded-lg bg-blue-100 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 p-3" },
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "flex items-center gap-2 text-sm font-semibold text-blue-900 dark:text-blue-100" }, "children": [ { "type": "basic", "name": "Icon", "props": { "name": "comment-dots", "size": "sm", "className": "text-blue-500 dark:text-blue-400" } }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_label" } ] },
+ { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_channel" } ] },
+ { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_template" } ] },
+ { "type": "basic", "name": "P", "props": { "className": "flex gap-2 ml-1 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" }, "children": [ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" }, { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.preparation.kakao_apikey" } ] }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "validation_error",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{_local.errors}}",
+ "props": {
+ "id": "bizppurio_settings_validation_error",
+ "role": "alert",
+ "aria-live": "assertive",
+ "tabIndex": -1,
+ "className": "mt-6 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",
+ "props": { "className": "text-error-soft" },
+ "text": "{{message}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "test_mode_card",
+ "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": "p-6" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex-between" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.test_mode.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.test_mode.hint"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.test_mode.account_notice"
+ }
+ ]
+ },
+ {
+ "type": "composite",
+ "name": "Toggle",
+ "props": {
+ "name": "is_test_mode"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "live_mode_warning",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{_local.form && !_local.form.is_test_mode}}",
+ "props": {
+ "className": "mt-6 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex gap-3" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "triangle-exclamation",
+ "size": "md",
+ "className": "text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "H3",
+ "props": { "className": "text-sm font-semibold text-warning-soft" },
+ "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_title"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "mt-1 text-warning-soft" },
+ "text": "$t:sirsoft-message_bizppurio.settings.live_mode_warning_body"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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": "panel-header-row" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "H3",
+ "props": { "className": "section-heading-md" },
+ "text": "$t:sirsoft-message_bizppurio.settings.sections.api.title"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint mt-1" },
+ "text": "$t:sirsoft-message_bizppurio.settings.sections.api.description"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "p-6 space-y-6" },
+ "children": [
+ {
+ "id": "field_bizppurio_id",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.bizppurio_id.hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "text",
+ "name": "bizppurio_id",
+ "autoComplete": "off",
+ "className": "{{_local.errors?.bizppurio_id ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}"
+ }
+ },
+ {
+ "id": "field_bizppurio_id_error",
+ "type": "basic",
+ "name": "P",
+ "if": "{{_local.errors?.bizppurio_id}}",
+ "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
+ "text": "{{_local.errors?.bizppurio_id?.[0] ?? ''}}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "field_password",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.password.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.password.hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "password",
+ "name": "password",
+ "autoComplete": "new-password",
+ "placeholder": "••••••••",
+ "className": "{{_local.errors?.password ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}"
+ }
+ },
+ {
+ "id": "field_password_error",
+ "type": "basic",
+ "name": "P",
+ "if": "{{_local.errors?.password}}",
+ "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
+ "text": "{{_local.errors?.password?.[0] ?? ''}}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "field_connection_check",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8 flex lg:justify-end" },
+ "children": [
+ {
+ "id": "connection_check_button",
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "disabled": "{{_local.tokenChecking}}",
+ "className": "btn btn-outline btn-sm flex-center gap-1.5"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "actions": [
+ {
+ "if": "{{_local.hasChanges}}",
+ "handler": "toast",
+ "params": {
+ "type": "warning",
+ "message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.unsaved_changes"
+ }
+ },
+ {
+ "if": "{{!_local.hasChanges}}",
+ "handler": "setState",
+ "params": { "target": "local", "tokenChecking": true }
+ },
+ {
+ "if": "{{!_local.hasChanges}}",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/token/check",
+ "params": { "method": "POST" },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": { "target": "local", "tokenChecking": false }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "type": "success",
+ "message": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.success"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": { "target": "local", "tokenChecking": false }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "type": "error",
+ "message": "{{error.errors?.bizppurio_message ?? error.message ?? '$t:sirsoft-message_bizppurio.settings.fields.connection_check.failed'}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "if": "{{_local.tokenChecking}}",
+ "props": {
+ "name": "spinner",
+ "className": "text-sm animate-spin"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Icon",
+ "if": "{{!_local.tokenChecking}}",
+ "props": {
+ "name": "plug",
+ "size": "sm"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!_local.tokenChecking}}",
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.button"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{_local.tokenChecking}}",
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.connection_check.checking"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "field_api_key",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.api_key.hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "password",
+ "name": "api_key",
+ "autoComplete": "new-password",
+ "placeholder": "••••••••",
+ "className": "{{_local.errors?.api_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}"
+ }
+ },
+ {
+ "id": "field_api_key_error",
+ "type": "basic",
+ "name": "P",
+ "if": "{{_local.errors?.api_key}}",
+ "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
+ "text": "{{_local.errors?.api_key?.[0] ?? ''}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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": "panel-header-row" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "H3",
+ "props": { "className": "section-heading-md" },
+ "text": "$t:sirsoft-message_bizppurio.settings.sections.sending.title"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint mt-1" },
+ "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",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_number.hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "text",
+ "name": "sender_number",
+ "autoComplete": "off",
+ "className": "{{_local.errors?.sender_number ? 'input border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input'}}"
+ }
+ },
+ {
+ "id": "field_sender_number_error",
+ "type": "basic",
+ "name": "P",
+ "if": "{{_local.errors?.sender_number}}",
+ "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
+ "text": "{{_local.errors?.sender_number?.[0] ?? ''}}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "field_sender_key",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.sender_key.hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "password",
+ "name": "sender_key",
+ "autoComplete": "new-password",
+ "placeholder": "••••••••",
+ "className": "{{_local.errors?.sender_key ? 'input font-mono border-red-500 dark:border-red-500 focus:ring-red-500 dark:focus:ring-red-400' : 'input font-mono'}}"
+ }
+ },
+ {
+ "id": "field_sender_key_error",
+ "type": "basic",
+ "name": "P",
+ "if": "{{_local.errors?.sender_key}}",
+ "props": { "className": "mt-1 text-sm text-red-600 dark:text-red-400" },
+ "text": "{{_local.errors?.sender_key?.[0] ?? ''}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "report_section",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-blue-200 dark:border-blue-800"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "px-6 py-4 border-b border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-900/20 rounded-t-lg"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex-center gap-2" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "bell",
+ "size": "sm",
+ "className": "text-blue-600 dark:text-blue-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "H3",
+ "props": { "className": "text-base font-semibold text-blue-900 dark:text-blue-100" },
+ "text": "$t:sirsoft-message_bizppurio.settings.report.section_title"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "p-6 space-y-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "text-label mb-2" },
+ "text": "$t:sirsoft-message_bizppurio.settings.report.hint"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex-center gap-2" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "text",
+ "readOnly": true,
+ "value": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}",
+ "className": "input-readonly flex-1 font-mono"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "flex-center gap-1.5 px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-body border border-gray-300 dark:border-gray-600 rounded-lg font-medium transition-colors whitespace-nowrap"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "actions": [
+ {
+ "handler": "copyToClipboard",
+ "params": {
+ "text": "{{report_url?.data?.url ?? '/api/plugins/sirsoft-message_bizppurio/webhook'}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "type": "success",
+ "message": "$t:sirsoft-message_bizppurio.settings.report.copied"
+ }
+ }
+ ]
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": { "name": "copy", "size": "sm" }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-message_bizppurio.settings.report.copy"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.report.note"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "cache_section",
+ "comment": "알림톡 발송 내용 캐시 — 독립 카드. 캐시 시간(form 저장) + 캐시 초기화(즉시 API, 로컬 캐시만 비움).",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "mt-6 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "panel-header-row" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "H3",
+ "props": { "className": "section-heading-md" },
+ "text": "$t:sirsoft-message_bizppurio.settings.cache.section_title"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint mt-1" },
+ "text": "$t:sirsoft-message_bizppurio.settings.cache.section_description"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "p-6 space-y-6" },
+ "children": [
+ {
+ "comment": "캐시 시간(분) — 좌: 라벨+설명 / 우: 입력. 다른 설정 필드와 동일한 2단 grid.",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.label"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8 flex lg:justify-end" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "number",
+ "name": "template_cache_minutes",
+ "min": "0",
+ "autoComplete": "off",
+ "className": "input w-32"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "캐시 초기화 — 좌: 안내문 / 우: 버튼. 즉시 로컬 캐시 비우기(kapi 호출 없음).",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-1 lg:grid-cols-12 gap-4 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": { "className": "form-label" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "form-hint" },
+ "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_hint"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "lg:col-span-8 flex lg:justify-end" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/cache/clear",
+ "params": { "method": "POST" },
+ "onSuccess": [
+ { "handler": "toast", "params": { "type": "success", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_success" } }
+ ],
+ "onError": [
+ { "handler": "toast", "params": { "type": "error", "message": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache_failed" } }
+ ]
+ }
+ ],
+ "children": [
+ { "type": "basic", "name": "Icon", "props": { "name": "eraser", "size": "sm" } },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.settings.fields.template_cache_minutes.clear_cache" }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "footer_buttons",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "sticky-footer-buttons" },
+ "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"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "templates_tab_panel",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(query.tab ?? 'connection') === 'templates'}}",
+ "props": { "className": "mt-6" },
+ "children": [
+ {
+ "id": "templates_list_error",
+ "comment": "목록 조회 실패 배너 — 준비 완료(ready)일 때만 노출. 키 미설정(ready=false)이면 아래 readiness 안내 배너가 담당하므로, 같은 원인으로 두 배너가 동시에 뜨지 않게 ready 게이트를 둔다. 즉 '키는 넣었는데 다른 이유로 실패'한 경우에만 카카오가 준 실제 사유를 표시.",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{_local.templateListError && templates_readiness?.data?.ready}}",
+ "props": { "className": "mb-4 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950 p-4 flex gap-2 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": { "name": "circle-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": "P",
+ "props": { "className": "text-sm font-medium text-red-800 dark:text-red-200" },
+ "text": "$t:sirsoft-message_bizppurio.templates.list_error.title"
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "text-xs text-red-700 dark:text-red-300 mt-1 leading-relaxed" },
+ "text": "{{_local.templateListError}}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "templates_readiness",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}",
+ "props": { "className": "mb-4 rounded-lg border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex-between gap-3" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex gap-2 items-start" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": { "name": "triangle-exclamation", "size": "md", "className": "text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "text-sm font-medium text-amber-800 dark:text-amber-200" },
+ "text": "$t:sirsoft-message_bizppurio.templates.readiness.title"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex gap-1.5 flex-wrap mt-1.5" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!(templates_readiness?.data?.api_key_set)}}",
+ "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" },
+ "text": "$t:sirsoft-message_bizppurio.templates.readiness.api_key_missing"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{!(templates_readiness?.data?.sender_key_set)}}",
+ "props": { "className": "inline-flex items-center px-2 py-0.5 rounded text-xs bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-100" },
+ "text": "$t:sirsoft-message_bizppurio.templates.readiness.sender_key_missing"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": { "type": "button", "className": "btn btn-secondary btn-sm whitespace-nowrap" },
+ "actions": [
+ { "type": "click", "handler": "navigate", "params": { "path": "/admin/plugins/{{route.identifier}}/settings", "query": { "tab": "connection" }, "mergeQuery": true, "replace": true } }
+ ],
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.readiness.go_settings" }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": { "className": "text-xs text-amber-700 dark:text-amber-300 mt-2 leading-relaxed" },
+ "text": "$t:sirsoft-message_bizppurio.templates.readiness.note"
+ }
+ ]
+ },
+ {
+ "id": "templates_list_view",
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "id": "templates_list_notice",
+ "comment": "조회 전용 안내 1박스(파랑). 상단: '상태 배지 읽는 법' 제목 + 배지명(의미색: 발송가능=초록/검수중=amber/미검수·반려=빨강) + 설명. 하단(구분선): 콘솔 위임 1줄 + 콘솔 링크. 제목/본문 sm.",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "mb-4 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-100 dark:border-blue-900 p-4" },
+ "children": [
+ {
+ "comment": "위 — 콘솔 위임 안내 1줄 + 콘솔 링크.",
+ "type": "basic", "name": "Div",
+ "props": { "className": "space-y-1" },
+ "children": [
+ {
+ "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" },
+ "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list_notice.console_desc" }
+ ]
+ },
+ {
+ "type": "basic", "name": "A",
+ "props": { "href": "https://www.bizppurio.com", "target": "_blank", "rel": "noopener noreferrer", "className": "inline-flex items-center gap-1.5 ml-4 text-sm font-medium text-blue-700 dark:text-blue-300 hover:underline" },
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list_notice.console_link" },
+ { "type": "basic", "name": "Icon", "props": { "name": "up-right-from-square", "size": "xs" } }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "구분선 아래 — 상태 배지 의미 3줄. 배지명은 표의 배지와 눈으로 매칭되도록 solid 의미색(다크에서도 뚜렷).",
+ "type": "basic", "name": "Div",
+ "props": { "className": "mt-3 pt-3 border-t border-blue-100 dark:border-blue-900 space-y-1.5" },
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-sm font-semibold text-blue-900 dark:text-blue-100 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.title" },
+ {
+ "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" },
+ "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" },
+ { "type": "basic", "name": "Span", "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-green-700 dark:text-green-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.sendable_label" },
+ { "type": "basic", "name": "Span", "text": " — " },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.sendable" }
+ ] }
+ ]
+ },
+ {
+ "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" },
+ "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" },
+ { "type": "basic", "name": "Span", "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-amber-700 dark:text-amber-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.inspecting_label" },
+ { "type": "basic", "name": "Span", "text": " — " },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.inspecting" }
+ ] }
+ ]
+ },
+ {
+ "type": "basic", "name": "P", "props": { "className": "flex gap-2 text-sm text-blue-800 dark:text-blue-200 leading-relaxed" },
+ "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "text-blue-400 dark:text-blue-500 flex-shrink-0" }, "text": "•" },
+ { "type": "basic", "name": "Span", "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "font-semibold text-red-700 dark:text-red-300" }, "text": "$t:sirsoft-message_bizppurio.templates.status_guide.pending_label" },
+ { "type": "basic", "name": "Span", "text": " — " },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.status_guide.pending" }
+ ] }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "templates_toolbar",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex-between gap-3 mb-4 flex-wrap" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex gap-2 items-center flex-wrap" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Select",
+ "props": {
+ "name": "templateStatus",
+ "value": "{{_local.templateStatus ?? ''}}",
+ "className": "input w-auto"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "sequence",
+ "actions": [
+ { "handler": "setState", "params": { "target": "local", "templateStatus": "{{$event.target.value}}", "templatePage": 1 } },
+ { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } }
+ ]
+ }
+ ],
+ "children": [
+ { "type": "basic", "name": "Option", "props": { "value": "" }, "text": "$t:sirsoft-message_bizppurio.templates.list.filter_all" },
+ { "type": "basic", "name": "Option", "props": { "value": "ACT" }, "text": "$t:sirsoft-message_bizppurio.templates.status.sendable" },
+ { "type": "basic", "name": "Option", "props": { "value": "REQ" }, "text": "$t:sirsoft-message_bizppurio.templates.status.inspecting" },
+ { "type": "basic", "name": "Option", "props": { "value": "REJ" }, "text": "$t:sirsoft-message_bizppurio.templates.status.rejected" },
+ { "type": "basic", "name": "Option", "props": { "value": "REG" }, "text": "$t:sirsoft-message_bizppurio.templates.status.uninspected" },
+ { "type": "basic", "name": "Option", "props": { "value": "STP" }, "text": "$t:sirsoft-message_bizppurio.templates.status.stopped" }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "text",
+ "value": "{{_local.templateKeyword ?? ''}}",
+ "placeholder": "$t:sirsoft-message_bizppurio.templates.list.search_placeholder",
+ "className": "input w-64"
+ },
+ "actions": [
+ { "type": "change", "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}" } },
+ {
+ "comment": "④ 엔터로 검색 실행 — 입력값을 상태에 반영 후 목록 refetch(검색 파라미터 반영)",
+ "type": "keypress",
+ "key": "Enter",
+ "handler": "sequence",
+ "actions": [
+ { "handler": "setState", "params": { "target": "local", "templateKeyword": "{{$event.target.value}}", "templatePage": 1 } },
+ { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "④ 검색 버튼 — 엔터 대신 클릭으로도 검색 실행",
+ "type": "basic",
+ "name": "Button",
+ "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" },
+ "actions": [
+ { "type": "click", "handler": "sequence", "actions": [
+ { "handler": "setState", "params": { "target": "local", "templatePage": 1 } },
+ { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } }
+ ] }
+ ],
+ "children": [
+ { "type": "basic", "name": "Icon", "props": { "name": "magnifying-glass", "size": "sm" } },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.search" }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "우측 액션 그룹 — 조회 전용(새로고침만). 캐시 초기화·캐시 시간은 환경설정 탭으로 이관. 템플릿 등록은 비즈뿌리오 콘솔로 위임.",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex gap-2 items-center" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": { "type": "button", "className": "btn btn-secondary btn-sm flex-center gap-1.5" },
+ "actions": [
+ { "type": "click", "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } }
+ ],
+ "children": [
+ { "type": "basic", "name": "Icon", "props": { "name": "rotate", "size": "sm" } },
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.list.refresh" }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "templates_table_card",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Table",
+ "props": { "className": "w-full text-sm" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Thead",
+ "props": { "className": "bg-gray-50 dark:bg-gray-700/50" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Tr",
+ "children": [
+ { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-16" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.no" },
+ { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.name" },
+ { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.code" },
+ { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.status" },
+ { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-40" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.requested_at" },
+ { "type": "basic", "name": "Th", "props": { "className": "text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-40" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.processed_at" },
+ { "type": "basic", "name": "Th", "props": { "className": "text-center px-4 py-3 font-medium text-gray-600 dark:text-gray-300 whitespace-nowrap w-16" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.actions" }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Tbody",
+ "if": "{{(alimtalk_templates?.data?.templates ?? []).length > 0}}",
+ "iteration": {
+ "source": "alimtalk_templates?.data?.templates ?? []",
+ "item_var": "tpl",
+ "index_var": "tplIndex"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Tr",
+ "props": { "className": "border-t border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700" },
+ "children": [
+ { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-gray-500 dark:text-gray-400 tabular-nums" }, "text": "{{((alimtalk_templates?.data?.pagination?.current_page ?? 1) - 1) * (alimtalk_templates?.data?.pagination?.per_page ?? 20) + tplIndex + 1}}" },
+ { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-medium text-gray-900 dark:text-gray-100" }, "text": "{{tpl.templateName ?? ''}}" },
+ { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 font-mono text-xs text-gray-500 dark:text-gray-400" }, "text": "{{tpl.templateCode ?? ''}}" },
+ {
+ "type": "basic",
+ "name": "Td",
+ "props": { "className": "px-4 py-3" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium {{tpl.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : tpl.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : tpl.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : tpl.status_badge?.variant === 'purple' ? 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}}"
+ },
+ "children": [
+ { "type": "basic", "name": "Span", "text": "{{$t(tpl.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" },
+ { "type": "basic", "name": "Span", "if": "{{tpl.service_status === 'RDY'}}", "props": { "className": "opacity-70" }, "text": "$t:sirsoft-message_bizppurio.templates.status_sub.rdy" }
+ ]
+ }
+ ]
+ },
+ { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" }, "text": "{{tpl.createdAt ?? '—'}}" },
+ { "type": "basic", "name": "Td", "props": { "className": "px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap" }, "text": "{{tpl.modifiedAt ?? '—'}}" },
+ {
+ "type": "basic",
+ "name": "Td",
+ "props": { "className": "px-4 py-3 w-20" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex items-center justify-center" },
+ "children": [
+ {
+ "comment": "조회 전용 — [내용] 버튼 하나. 상세 조회 후 내용 모달 열기(관리 액션 없음).",
+ "type": "basic",
+ "name": "Button",
+ "props": { "type": "button", "className": "btn btn-secondary btn-xs flex-center gap-1 whitespace-nowrap" },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/{{tpl.templateCode}}",
+ "params": { "method": "GET" },
+ "onSuccess": [
+ { "handler": "setState", "params": { "target": "local", "templateDetail": "{{response.data.template}}" } },
+ { "handler": "openModal", "target": "alimtalk_template_detail_modal" }
+ ]
+ }
+ ],
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.actions.detail" }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Tbody",
+ "if": "{{(alimtalk_templates?.data?.templates ?? []).length === 0}}",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Tr",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Td",
+ "props": { "className": "px-4 py-12 text-center text-gray-400 dark:text-gray-500", "colSpan": 7 },
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "font-medium" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty" },
+ { "type": "basic", "name": "P", "props": { "className": "text-xs mt-1" }, "text": "$t:sirsoft-message_bizppurio.templates.list.empty_hint" }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "페이지네이션 — 항상 노출(총 1페이지여도 표시). 페이지 변경 시 _local.templatePage 갱신 후 목록 refetch.",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex justify-center mt-4" },
+ "children": [
+ {
+ "type": "composite",
+ "name": "Pagination",
+ "props": {
+ "currentPage": "{{alimtalk_templates?.data?.pagination?.current_page ?? 1}}",
+ "totalPages": "{{alimtalk_templates?.data?.pagination?.total_page ?? 1}}"
+ },
+ "actions": [
+ {
+ "event": "onPageChange",
+ "type": "action",
+ "handler": "sequence",
+ "actions": [
+ { "handler": "setState", "params": { "target": "local", "templatePage": "{{$args[0]}}" } },
+ { "handler": "refetchDataSource", "params": { "dataSourceId": "alimtalk_templates" } }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "modals": [
+ {
+ "id": "alimtalk_template_detail_modal",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.templates.detail.title",
+ "size": "lg"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "space-y-3 text-gray-900 dark:text-gray-100" },
+ "children": [
+ {
+ "comment": "A안 헤더 — 템플릿명 + 상태 배지(⑦) 한 줄. 배지는 RDY 세부(사용전)까지 표기.",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex items-start justify-between gap-3" },
+ "children": [
+ { "type": "basic", "name": "H3", "props": { "className": "text-base font-semibold text-gray-900 dark:text-gray-100 leading-snug" }, "text": "{{_local.templateDetail?.templateName ?? ''}}" },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 {{_local.templateDetail?.status_badge?.variant === 'green' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : _local.templateDetail?.status_badge?.variant === 'yellow' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : _local.templateDetail?.status_badge?.variant === 'red' ? 'bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200' : _local.templateDetail?.status_badge?.variant === 'purple' ? 'bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300'}}"
+ },
+ "children": [
+ { "type": "basic", "name": "Span", "text": "{{$t(_local.templateDetail?.status_badge?.label_key ?? 'sirsoft-message_bizppurio.templates.status.unknown')}}" },
+ { "type": "basic", "name": "Span", "if": "{{_local.templateDetail?.service_status === 'RDY'}}", "props": { "className": "opacity-70" }, "text": "$t:sirsoft-message_bizppurio.templates.status_sub.rdy" }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "A안 메타 그리드 — 코드·카테고리·유형·등록요청일·처리일 압축 표시. 없는 값은 '—'.",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "grid grid-cols-2 gap-x-4 gap-y-2 pb-3 border-b border-gray-200 dark:border-gray-700" },
+ "children": [
+ {
+ "type": "basic", "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.code" },
+ { "type": "basic", "name": "P", "props": { "className": "font-mono text-sm text-gray-700 dark:text-gray-200 break-all" }, "text": "{{_local.templateDetail?.templateCode ?? '—'}}" }
+ ]
+ },
+ {
+ "type": "basic", "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.category" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.categoryName ?? _local.templateDetail?.categoryCode ?? '—'}}" }
+ ]
+ },
+ {
+ "type": "basic", "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.emphasize_type" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateEmphasizeType === 'TEXT' ? $t('sirsoft-message_bizppurio.templates.detail.type_text') : _local.templateDetail?.templateEmphasizeType === 'IMAGE' ? $t('sirsoft-message_bizppurio.templates.detail.type_image') : $t('sirsoft-message_bizppurio.templates.detail.type_none')}}" }
+ ]
+ },
+ {
+ "type": "basic", "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.requested_at" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.createdAt ?? '—'}}" }
+ ]
+ },
+ {
+ "type": "basic", "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.list.columns.processed_at" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.modifiedAt ?? '—'}}" }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "강조표기형(TEXT)일 때만 — 강조문구/보조문구",
+ "type": "basic", "name": "Div",
+ "if": "{{_local.templateDetail?.templateEmphasizeType === 'TEXT'}}",
+ "props": { "className": "grid grid-cols-2 gap-x-4" },
+ "children": [
+ {
+ "type": "basic", "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.title_field" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateTitle ?? '—'}}" }
+ ]
+ },
+ {
+ "type": "basic", "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-0.5" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.subtitle_field" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-800 dark:text-gray-100" }, "text": "{{_local.templateDetail?.templateSubtitle ?? '—'}}" }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "A안 — 내용을 가장 크게 강조",
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.content" },
+ { "type": "basic", "name": "Div", "props": { "className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded p-3 whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-100 leading-relaxed" }, "text": "{{_local.templateDetail?.templateContent ?? ''}}" }
+ ]
+ },
+ {
+ "comment": "이미지형(IMAGE)일 때만 — 미리보기 이미지",
+ "type": "basic", "name": "Div",
+ "if": "{{_local.templateDetail?.templateEmphasizeType === 'IMAGE' && _local.templateDetail?.templateImageUrl}}",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.image_upload" },
+ { "type": "basic", "name": "Img", "props": { "src": "{{_local.templateDetail?.templateImageUrl}}", "className": "w-24 h-24 object-cover rounded border border-gray-200 dark:border-gray-700" } }
+ ]
+ },
+ {
+ "comment": "버튼 목록 — 있을 때만. 각 버튼 = 이름 + 링크타입 배지 + 링크 URL(있으면).",
+ "type": "basic", "name": "Div",
+ "if": "{{(_local.templateDetail?.buttons ?? []).length > 0}}",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.buttons" },
+ {
+ "type": "basic", "name": "Div",
+ "props": { "className": "space-y-1.5" },
+ "iteration": { "source": "_local.templateDetail?.buttons ?? []", "item_var": "btn" },
+ "children": [
+ {
+ "type": "basic", "name": "Div",
+ "props": { "className": "flex items-center gap-2 flex-wrap rounded border border-gray-200 dark:border-gray-700 px-2.5 py-1.5" },
+ "children": [
+ { "type": "basic", "name": "Span", "props": { "className": "text-sm font-medium text-gray-800 dark:text-gray-100" }, "text": "{{btn.name ?? ''}}" },
+ { "type": "basic", "name": "Span", "props": { "className": "inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200 flex-shrink-0" }, "text": "{{$t('sirsoft-message_bizppurio.templates.link_type.' + (btn.linkType ?? 'WL'))}}" },
+ { "type": "basic", "name": "Span", "if": "{{btn.linkMo || btn.linkPc || btn.linkAnd || btn.linkIos}}", "props": { "className": "font-mono text-xs text-gray-500 dark:text-gray-400 break-all" }, "text": "{{btn.linkMo || btn.linkPc || btn.linkAnd || btn.linkIos}}" }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "comment": "부가정보 — 있을 때만",
+ "type": "basic", "name": "Div",
+ "if": "{{_local.templateDetail?.templateExtra}}",
+ "children": [
+ { "type": "basic", "name": "P", "props": { "className": "text-xs font-medium text-gray-500 dark:text-gray-400 mb-1" }, "text": "$t:sirsoft-message_bizppurio.templates.detail.extra" },
+ { "type": "basic", "name": "P", "props": { "className": "text-sm text-gray-700 dark:text-gray-200 whitespace-pre-wrap" }, "text": "{{_local.templateDetail?.templateExtra}}" }
+ ]
+ },
+ {
+ "comment": "푸터 — 조회 전용. [닫기]만 노출(관리 액션 없음, 등록·상태변경은 비즈뿌리오 콘솔).",
+ "type": "basic",
+ "name": "Div",
+ "props": { "className": "flex items-center justify-end gap-2 pt-3 border-t border-gray-200 dark:border-gray-700" },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": { "type": "button", "className": "btn btn-secondary flex-shrink-0" },
+ "actions": [
+ { "type": "click", "handler": "closeModal", "target": "alimtalk_template_detail_modal" }
+ ],
+ "children": [
+ { "type": "basic", "name": "Span", "text": "$t:sirsoft-message_bizppurio.templates.detail.close" }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php
new file mode 100644
index 00000000..0694e26f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php
@@ -0,0 +1,42 @@
+ $e->getMessage(),
+ 'result_code' => $e->getResultCode(),
+ ],
+ );
+ }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php
new file mode 100644
index 00000000..df306291
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/PreventsReplayWebhook.php
@@ -0,0 +1,43 @@
+reported_at !== null;
+ }
+
+ /**
+ * replay 감지를 통일된 형식으로 로깅합니다 (운영 모니터링용).
+ *
+ * @param string $refkey 우리 부여 키
+ * @param string|null $resultCode 리포트 결과 코드
+ */
+ protected function logReplayDetected(string $refkey, ?string $resultCode): void
+ {
+ Log::info('비즈뿌리오 webhook: replay 감지 — 이미 리포트 반영됨, 멱등 응답', [
+ 'refkey' => $refkey,
+ 'result_code' => $resultCode,
+ ]);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php
new file mode 100644
index 00000000..fe007bab
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php
@@ -0,0 +1,118 @@
+guard(function () use ($request) {
+ $result = $this->service->list($request->filters());
+
+ return ResponseHelper::success('messages.success', $result);
+ });
+ }
+
+ /**
+ * 알림톡 템플릿 상세를 실시간 조회합니다.
+ *
+ * @param string $templateCode 템플릿 코드
+ * @return JsonResponse data.template 에 배지·가능 액션이 부가된 상세
+ */
+ public function show(string $templateCode): JsonResponse
+ {
+ return $this->guard(fn () => ResponseHelper::success('messages.success', [
+ 'template' => $this->service->detail($templateCode),
+ ]));
+ }
+
+ /**
+ * 템플릿 등록에 사용할 카테고리 전체를 조회합니다.
+ *
+ * @return JsonResponse data.categories 에 대분류·소분류 목록
+ */
+ public function categories(): JsonResponse
+ {
+ return $this->guard(fn () => ResponseHelper::success('messages.success', [
+ 'categories' => $this->service->categories(),
+ ]));
+ }
+
+ /**
+ * 발신프로필(사용중) 정보를 조회합니다.
+ *
+ * @return JsonResponse data.profiles 에 발신프로필 상태 정보
+ */
+ public function profiles(): JsonResponse
+ {
+ return $this->guard(fn () => ResponseHelper::success('messages.success', [
+ 'profiles' => $this->service->senderProfiles(),
+ ]));
+ }
+
+ /**
+ * 발송용 템플릿 내용 캐시를 초기화합니다 (관리자 수동 갱신).
+ *
+ * 카카오에서 템플릿 내용을 방금 변경해 캐시 만료(기본 1시간)를 기다리지 않고 즉시
+ * 반영하고 싶을 때 호출한다. 연결된 모든 알림톡 템플릿의 캐시를 비워, 다음 발송에서
+ * 최신 내용으로 재조회되게 한다. kapi 호출 없이 로컬 캐시만 비우므로 rate limit 영향 없음.
+ *
+ * @return JsonResponse data.cleared 에 초기화한 캐시 수
+ */
+ public function clearCache(): JsonResponse
+ {
+ $cleared = $this->bindings->clearTemplateContentCache();
+
+ return ResponseHelper::success('messages.cache.cleared', [
+ 'cleared' => $cleared,
+ ]);
+ }
+
+ // kapi 호출을 감싸 BizppurioApiException 을 422 응답으로 변환하는 guard() 는
+ // GuardsKakaoRequests 트레이트로 이관(연동 컨트롤러와 공유).
+ // 카카오가 준 실패 사유(message)를 그대로 노출해 운영자가 조회 실패 원인을 즉시 파악한다.
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php
new file mode 100644
index 00000000..c3b4f21e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/DispatchResultController.php
@@ -0,0 +1,61 @@
+ $this->service->resultsForLogIds($request->logIds()),
+ ]);
+ }
+
+ /**
+ * 최근 연결된 비즈뿌리오 결과 맵을 반환합니다 (코어 이력 화면 결과 컬럼용, 타이밍 무관).
+ *
+ * 파라미터 없이 GET 으로 최근 결과 맵을 받아 화면이 row.id 로 매칭한다(kginicis test-map 선례).
+ * 목록 data_source(notificationLogs) 로드 순서에 의존하지 않아, 결과 컬럼이 비는 문제를 원천 차단.
+ *
+ * @return JsonResponse data.results 에 notification_log_id → 결과 맵
+ */
+ public function recent(): JsonResponse
+ {
+ return ResponseHelper::success('messages.success', [
+ 'results' => $this->service->recentResults(),
+ ]);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php
new file mode 100644
index 00000000..b74b9e47
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php
@@ -0,0 +1,99 @@
+ $this->service->all(withAvailability: true),
+ ]);
+ }
+
+ /**
+ * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 드롭다운).
+ *
+ * kapi 실패는 카카오가 준 사유를 그대로 422 로 반환한다.
+ *
+ * @return JsonResponse data.templates 에 승인 템플릿(code/name) 배열, 실패 시 422
+ */
+ public function approvedTemplates(): JsonResponse
+ {
+ return $this->guard(fn () => ResponseHelper::success('messages.success', [
+ 'templates' => $this->service->approvedTemplates(),
+ ]));
+ }
+
+ /**
+ * 연결 템플릿·SMS 대체를 즉시 저장합니다 (전용 칸 변경 시 자동 호출).
+ *
+ * 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다("빈 코드=해제" 규칙 —
+ * 드롭다운에서 "연결 안 함"을 고르면 해제까지 한 번에 처리). SMS 대체는 연결이 있을 때만
+ * 의미가 있다.
+ *
+ * @param StoreNotificationBindingRequest $request 검증된 연동 입력
+ * @return JsonResponse 저장 결과 (해제 시에도 200)
+ */
+ public function store(StoreNotificationBindingRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+
+ return $this->guard(function () use ($validated) {
+ $this->service->applyFromTemplateSave(
+ $validated['notification_type'],
+ $validated['template_code'] ?? null,
+ $validated['template_name'] ?? null,
+ (bool) ($validated['fallback_sms_enabled'] ?? false),
+ );
+
+ return ResponseHelper::success('messages.binding.saved', [
+ 'bindings' => $this->service->all(),
+ ]);
+ });
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php
new file mode 100644
index 00000000..d108e9fb
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/TokenCheckController.php
@@ -0,0 +1,66 @@
+tokenService->verifyCredentials();
+
+ return ResponseHelper::success('sirsoft-message_bizppurio::messages.token_check.success');
+ } catch (BizppurioApiException $e) {
+ // 비즈뿌리오가 준 실패 사유 원문은 메시지 키가 아니라 관리자 전용 errors
+ // 페이로드로 전달한다 (키 자리에 원문 전달 금지 — docs/backend/exceptions.md).
+ return ResponseHelper::error(
+ 'sirsoft-message_bizppurio::messages.token_check.failed',
+ 422,
+ [
+ 'bizppurio_message' => $e->getMessage(),
+ 'result_code' => $e->getResultCode(),
+ ],
+ );
+ } catch (ConnectionException) {
+ return ResponseHelper::error(
+ 'sirsoft-message_bizppurio::messages.error.connection_failed',
+ 422,
+ );
+ }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php
new file mode 100644
index 00000000..633d3453
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/BizppurioWebhookController.php
@@ -0,0 +1,49 @@
+reports->apply([
+ 'REFKEY' => $request->input('REFKEY'),
+ 'RESULT' => $request->input('RESULT'),
+ 'MEDIA' => $request->input('MEDIA'),
+ 'TELRES' => $request->input('TELRES'),
+ 'KAORES' => $request->input('KAORES'),
+ 'raw' => $request->all(),
+ ]);
+
+ return ResponseHelper::success('sirsoft-message_bizppurio::messages.webhook.received');
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php
new file mode 100644
index 00000000..58ede0a3
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchChannel.php
@@ -0,0 +1,52 @@
+ __('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 채널 문자열 배열 (예: ["sms", "lms", "alimtalk"])
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php
new file mode 100644
index 00000000..a81cd214
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchSource.php
@@ -0,0 +1,42 @@
+ __('sirsoft-message_bizppurio::messages.source.auto'),
+ self::Manual => __('sirsoft-message_bizppurio::messages.source.manual'),
+ self::Bulk => __('sirsoft-message_bizppurio::messages.source.bulk'),
+ };
+ }
+
+ /**
+ * 모든 출처 값 목록을 반환합니다.
+ *
+ * @return array 출처 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php
new file mode 100644
index 00000000..0377e545
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/DispatchStatus.php
@@ -0,0 +1,54 @@
+ __('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 상태 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php
new file mode 100644
index 00000000..4475233d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/ResultCategory.php
@@ -0,0 +1,69 @@
+ __('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 분류 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php
new file mode 100644
index 00000000..cceb808b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioApiException.php
@@ -0,0 +1,60 @@
+resultCode;
+ }
+
+ /**
+ * HTTP 상태 코드를 반환합니다.
+ *
+ * @return int|null HTTP 상태 코드, 없으면 null
+ */
+ public function getHttpStatus(): ?int
+ {
+ return $this->httpStatus;
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php
new file mode 100644
index 00000000..b52dde93
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/NotificationSendSkippedException.php
@@ -0,0 +1,18 @@
+ip(), self::ALLOWED_IPS, true)) {
+ abort(403, 'Forbidden');
+ }
+
+ return $next($request);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php
new file mode 100644
index 00000000..f0e1112c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php
@@ -0,0 +1,58 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'status' => ['nullable', 'string', 'max:30'],
+ 'keyword' => ['nullable', 'string', 'max:50'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ 'count' => ['nullable', 'integer', 'min:1'],
+ ];
+ }
+
+ /**
+ * 서비스 list() 에 전달할 필터 배열을 반환합니다.
+ *
+ * @return array status·keyword·page·count (미전달 키는 null)
+ */
+ public function filters(): array
+ {
+ return [
+ 'status' => $this->validated('status'),
+ 'keyword' => $this->validated('keyword'),
+ 'page' => $this->validated('page'),
+ 'count' => $this->validated('count'),
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php
new file mode 100644
index 00000000..6dbaaedc
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioWebhookRequest.php
@@ -0,0 +1,49 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'DEVICE' => ['nullable', 'string', 'max:20'],
+ 'CMSGID' => ['nullable', 'string', 'max:64'],
+ 'MSGID' => ['nullable', 'string', 'max:64'],
+ 'PHONE' => ['nullable', 'string', 'max:20'],
+ 'MEDIA' => ['nullable', 'string', 'max:10'],
+ 'RESULT' => ['required', 'string', 'max:10'],
+ 'REFKEY' => ['required', 'string', 'max:32'],
+ 'TELRES' => ['nullable', 'string', 'max:10'],
+ 'KAORES' => ['nullable', 'string', 'max:10'],
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php
new file mode 100644
index 00000000..cfaba4a9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/DispatchResultLookupRequest.php
@@ -0,0 +1,52 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ // 빈 배열 허용: 코어 알림 발송 이력이 0건(또는 로드 전)이면 화면이 빈 배열을 보낸다.
+ // 이때 422 로 막으면 결과 컬럼 조회 자체가 에러가 되므로, present(키 존재)만 요구하고
+ // 빈 배열은 통과시켜 빈 결과 맵을 돌려준다.
+ 'notification_log_ids' => ['present', 'array', 'max:100'],
+ 'notification_log_ids.*' => ['integer', 'min:1'],
+ ];
+ }
+
+ /**
+ * 검증된 코어 알림 로그 id 목록을 정수 배열로 반환합니다.
+ *
+ * @return array 로그 id 목록
+ */
+ public function logIds(): array
+ {
+ return array_map('intval', $this->validated('notification_log_ids', []));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php
new file mode 100644
index 00000000..c9cfd3a7
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php
@@ -0,0 +1,41 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'notification_type' => ['required', 'string', 'max:100'],
+ 'template_code' => ['nullable', 'string', 'max:50'],
+ 'template_name' => ['nullable', 'string', 'max:255'],
+ 'fallback_sms_enabled' => ['sometimes', 'boolean'],
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php
new file mode 100644
index 00000000..22a082b8
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Jobs/SendMessageJob.php
@@ -0,0 +1,176 @@
+ $payload 발송 payload (MessagePayloadBuilder 조립)
+ * @param string $refkey 우리 부여 키 (이력 매칭용)
+ */
+ public function __construct(
+ public readonly array $payload,
+ public readonly string $refkey,
+ ) {
+ $this->afterCommit = true;
+ }
+
+ /**
+ * 발송을 수행하고 결과코드에 따라 재시도/실패를 판정합니다.
+ *
+ * 발송 응답으로 이력(bizppurio_dispatches)을 갱신한다(Phase 4):
+ * - 성공: status=sent(발송 접수 완료, 최종 성공은 webhook 리포트가 확정) + messagekey 저장.
+ * - 재시도: 예외를 던져 큐 재시도(이력은 pending 유지).
+ * - 영구 실패: status=failed + result_code 기록.
+ *
+ * @param BizppurioApiClient $client 발송 API 클라이언트
+ * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리
+ *
+ * @throws BizppurioApiException 일시 오류·429 시(큐 재시도 트리거)
+ */
+ public function handle(BizppurioApiClient $client, BizppurioDispatchRepositoryInterface $dispatches): void
+ {
+ $result = $client->sendMessage($this->payload);
+ $code = (string) ($result['code'] ?? '');
+
+ if ($client->isSuccess($result)) {
+ // 발송 접수 성공 → sent(최종 성공/실패는 webhook 리포트가 확정) + messagekey 저장.
+ $this->updateDispatch($dispatches, [
+ 'status' => DispatchStatus::Sent->value,
+ 'messagekey' => $result['messagekey'] ?? null,
+ ]);
+
+ return;
+ }
+
+ if (in_array($code, self::RETRYABLE_CODES, true)) {
+ throw new BizppurioApiException(
+ __('sirsoft-message_bizppurio::messages.error.send_retryable', ['code' => $code]),
+ resultCode: $code,
+ );
+ }
+
+ // 영구 실패: 재시도하지 않고 종료 + 실패 이력 기록.
+ $this->updateDispatch($dispatches, [
+ 'status' => DispatchStatus::Failed->value,
+ 'result_code' => $code,
+ 'result_message' => $result['description'] ?? null,
+ ]);
+
+ Log::warning('비즈뿌리오 발송 영구 실패', [
+ 'refkey' => $this->refkey,
+ 'code' => $code,
+ 'description' => $result['description'] ?? null,
+ ]);
+ }
+
+ /**
+ * refkey 로 발송 이력을 조회해 갱신합니다 (이력이 없으면 무시).
+ *
+ * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 리포지토리
+ * @param array $data 갱신 데이터
+ */
+ private function updateDispatch(BizppurioDispatchRepositoryInterface $dispatches, array $data): void
+ {
+ $dispatch = $dispatches->findByRefkey($this->refkey);
+ if ($dispatch !== null) {
+ $dispatches->update($dispatch, $data);
+ }
+ }
+
+ /**
+ * 재시도 사이 대기 시간(초)을 반환합니다.
+ *
+ * 429·일시오류 재시도 시 짧게 backoff 한다. sync 실행 시에도 이 값이 적용된다.
+ *
+ * @return int 대기 초
+ */
+ public function backoff(): int
+ {
+ return self::SYNC_RETRY_MAX_WAIT_SECONDS;
+ }
+
+ /**
+ * 모든 재시도 소진 후 최종 실패 시 로그를 남기고 발송 이력을 실패로 마감합니다.
+ *
+ * 타임아웃·연결실패(ConnectionException)는 결과코드 없는 예외로 handle() 의 상태 갱신
+ * 분기를 우회하므로, 재시도 소진 후 이력이 pending 인 채 방치된다. 여기서 refkey 로 이력을
+ * 조회해 failed 로 마감한다(전송 실패라 최종 실패는 webhook 으로도 회수 불가). 이미 확정
+ * (success/failed)된 이력은 webhook 이 먼저 결과를 확정한 경우이므로 덮어쓰지 않는다(멱등).
+ *
+ * @param Throwable $exception 마지막으로 발생한 예외
+ */
+ public function failed(Throwable $exception): void
+ {
+ $resultCode = $exception instanceof BizppurioApiException
+ ? $exception->getResultCode()
+ : null;
+
+ Log::error('비즈뿌리오 발송 Job 최종 실패', [
+ 'refkey' => $this->refkey,
+ 'error' => $exception->getMessage(),
+ 'result_code' => $resultCode,
+ ]);
+
+ $dispatches = app(BizppurioDispatchRepositoryInterface::class);
+ $dispatch = $dispatches->findByRefkey($this->refkey);
+
+ // 이력 없음(비정상) 또는 이미 확정(webhook 선반영) → 마감 불필요(멱등)
+ if ($dispatch === null || $dispatch->status->isFinal()) {
+ return;
+ }
+
+ $dispatches->update($dispatch, [
+ 'status' => DispatchStatus::Failed->value,
+ 'result_code' => $resultCode,
+ 'result_message' => $exception->getMessage(),
+ ]);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php
new file mode 100644
index 00000000..996492f6
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/BalanceLowNotificationDataListener.php
@@ -0,0 +1,110 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'sirsoft-message_bizppurio.notification.extract_data' => [
+ 'method' => 'injectBalanceLowData',
+ 'priority' => 10,
+ 'type' => 'filter',
+ ],
+ ];
+ }
+
+ /**
+ * 기본 핸들러 (미사용 — 필터 메서드로 처리).
+ *
+ * @param mixed ...$args
+ */
+ public function handle(...$args): void {}
+
+ /**
+ * 잔액부족 알림의 data 에 본문 치환 변수를 채웁니다.
+ *
+ * extract_data 결과(`{notifiable, notifiables, data, context}`)를 받아, 대상이
+ * 잔액부족 알림이면 훅 인자(결과코드·채널)와 사이트 메타를 data 에 덧붙여 반환한다.
+ * 그 외 유형은 원본을 그대로 통과시킨다. `name` 은 코어가 수신자별로 치환하도록
+ * `{recipient_name}` placeholder 를 넣는다(NotificationHookListener 폴백 규약).
+ *
+ * @param array $result extract_data 결과
+ * @param string $type 알림 정의 유형
+ * @param array $args 훅 원본 인수 ([$resultCode, $channel])
+ * @return array 변수가 채워진(또는 원본) 결과
+ */
+ public function injectBalanceLowData(array $result, string $type, array $args): array
+ {
+ if ($type !== self::NOTIFICATION_TYPE) {
+ return $result;
+ }
+
+ $resultCode = (string) ($args[0] ?? '');
+ $channel = (string) ($args[1] ?? '');
+
+ $result['data'] = array_merge(
+ $result['data'] ?? [],
+ [
+ 'name' => '{recipient_name}',
+ 'app_name' => (string) config('app.name', ''),
+ 'result_code' => $resultCode,
+ 'channel_label' => $this->channelLabel($channel),
+ 'settings_url' => rtrim((string) config('app.url', ''), '/').self::SETTINGS_PATH,
+ 'site_url' => (string) config('app.url', ''),
+ ],
+ );
+
+ return $result;
+ }
+
+ /**
+ * 채널 문자열을 잔액부족 알림용 채널군 라벨(문자/알림톡)로 변환합니다.
+ *
+ * SMS/LMS 는 "문자" 로 묶고 알림톡은 "알림톡" 으로 표기한다. 알 수 없는 채널은
+ * 원본 문자열을 그대로 반환한다(치환 실패보다 원본 노출이 안전).
+ *
+ * @param string $channel 채널 문자열 (sms/lms/alimtalk)
+ * @return string 채널군 라벨
+ */
+ private function channelLabel(string $channel): string
+ {
+ $enum = DispatchChannel::tryFrom($channel);
+
+ if ($enum === null) {
+ return $channel;
+ }
+
+ return $enum->isText()
+ ? __('sirsoft-message_bizppurio::messages.channel_group.text')
+ : __('sirsoft-message_bizppurio::messages.channel_group.alimtalk');
+ }
+}
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php
new file mode 100644
index 00000000..985d8a92
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/GuestPhoneExtractListener.php
@@ -0,0 +1,97 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'sirsoft-ecommerce.notification.extract_data' => [
+ 'method' => 'injectGuestPhone',
+ 'priority' => 20,
+ 'type' => 'filter',
+ ],
+ ];
+ }
+
+ /**
+ * 기본 핸들러 (미사용 — 필터 메서드로 처리).
+ *
+ * @param mixed ...$args
+ */
+ public function handle(...$args): void {}
+
+ /**
+ * 비회원 주문 알림의 data 에 수신 전화번호를 주입합니다.
+ *
+ * 이커머스 리스너가 만든 extract_data 결과(`{notifiable, notifiables, data, context}`)를
+ * 받아, 대상이 주문 알림이고 비회원 주문이면 data 에 전화번호를 덧붙여 반환한다.
+ * 그 외에는 원본을 그대로 통과시킨다(회원 주문은 드라이버가 회원 mobile 을 사용).
+ *
+ * @param array $result 이커머스 extract_data 결과
+ * @param string $type 알림 정의 유형
+ * @param array $args 훅 원본 인수 ([$order, ...])
+ * @return array 전화번호가 보강된(또는 원본) 결과
+ */
+ public function injectGuestPhone(array $result, string $type, array $args): array
+ {
+ if (! in_array($type, self::ORDER_NOTIFICATION_TYPES, true)) {
+ return $result;
+ }
+
+ $order = $args[0] ?? null;
+ if (! $order instanceof Order || ! $order->isGuestOrder()) {
+ return $result;
+ }
+
+ // 주문자 전화번호(배송지 orderer_phone)를 이커머스 자체 관행(GuestOrderAuthService·
+ // OrderResource 와 동일)대로 관계 필드에서 직접 읽는다. 이커머스 모듈 무수정.
+ $phone = $order->shippingAddress?->orderer_phone;
+ if ($phone === null || trim((string) $phone) === '') {
+ return $result;
+ }
+
+ $result['data'] = array_merge(
+ $result['data'] ?? [],
+ [SmsChannelDriver::RECIPIENT_PHONE_KEY => $phone],
+ );
+
+ return $result;
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php
new file mode 100644
index 00000000..ac92c6f6
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/InvalidateTokenOnSettingsSaveListener.php
@@ -0,0 +1,83 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.plugin_settings.after_save' => [
+ 'method' => 'invalidateToken',
+ 'priority' => 10,
+ 'type' => 'action',
+ 'sync' => true,
+ ],
+ ];
+ }
+
+ /**
+ * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='invalidateToken' 를 명시하므로
+ * 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다.
+ *
+ * @param mixed ...$args 사용 안 함
+ */
+ public function handle(...$args): void
+ {
+ // no-op — 실제 진입점은 invalidateToken() 메서드 (action 훅)
+ }
+
+ /**
+ * 본 플러그인 설정 저장 직후 토큰 캐시를 무효화한다.
+ *
+ * @param string $identifier 저장된 플러그인 식별자
+ * @param array $settings 저장된 설정(사용 안 함)
+ * @param bool $result 저장 성공 여부
+ */
+ public function invalidateToken(string $identifier, array $settings, bool $result): void
+ {
+ if ($identifier !== self::IDENTIFIER || ! $result) {
+ return;
+ }
+
+ $this->tokenService->forget();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php
new file mode 100644
index 00000000..0f0f966b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/LinkNotificationLogListener.php
@@ -0,0 +1,90 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.notification_log.after_log_sent' => ['method' => 'linkLog', 'priority' => 15],
+ 'core.notification_log.after_log_failed' => ['method' => 'linkLog', 'priority' => 15],
+ ];
+ }
+
+ /**
+ * 기본 핸들러 (미사용 — linkLog 로 처리).
+ *
+ * @param mixed ...$args
+ */
+ public function handle(...$args): void {}
+
+ /**
+ * 방금 생성된 코어 알림 로그를 이번 발송 사이클의 dispatch 에 연결합니다.
+ *
+ * 이 사이클에서 우리 드라이버가 남긴 refkey 가 있으면 그 dispatch 에 로그 id 를 기록한다.
+ * refkey 가 없으면(비-비즈뿌리오 로그) 연결하지 않는다.
+ *
+ * @param NotificationLog $log 코어가 방금 생성한 로그(id 포함)
+ */
+ public function linkLog(NotificationLog $log): void
+ {
+ $refkey = $this->linkContext->consume();
+
+ if ($refkey === null) {
+ return;
+ }
+
+ try {
+ $this->dispatches->linkNotificationLog($refkey, (int) $log->id);
+ } catch (\Throwable $e) {
+ Log::warning('비즈뿌리오: 코어 알림 로그 연결 실패', [
+ 'refkey' => $refkey,
+ 'notification_log_id' => $log->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php
new file mode 100644
index 00000000..3754868a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php
@@ -0,0 +1,373 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ $hooks = [
+ 'core.notification.filter_available_channels' => [
+ 'method' => 'addChannels',
+ 'priority' => 20,
+ 'type' => 'filter',
+ ],
+ 'core.notification.channel_readiness' => [
+ 'method' => 'checkReadiness',
+ 'priority' => 20,
+ 'type' => 'filter',
+ ],
+ 'core.notification.channel_enabled' => [
+ 'method' => 'gateChannelEnabled',
+ 'priority' => 20,
+ 'type' => 'filter',
+ ],
+ ];
+
+ foreach (self::CHANNEL_HOOK_PREFIXES as $prefix) {
+ $hooks["{$prefix}.notification.channels"] = [
+ 'method' => 'addChannelCandidates',
+ 'priority' => 20,
+ 'type' => 'filter',
+ ];
+ }
+
+ return $hooks;
+ }
+
+ /**
+ * 기본 핸들러 (미사용 — 필터 메서드로 처리).
+ *
+ * @param mixed ...$args
+ */
+ public function handle(...$args): void {}
+
+ /**
+ * 사용 가능한 채널 목록에 sms·alimtalk 메타를 추가합니다.
+ *
+ * 코어 NotificationChannelService::getAvailableChannels() 가 name_key/description_key/
+ * source_label_key 를 활성 locale 로 해석하므로(localized_payload), lang key 로 선언한다.
+ * 중복 방지: 이미 존재하는 id 는 다시 추가하지 않는다.
+ *
+ * allow_guest:true — 비회원 문자 발송 허용(D1). 실제 게스트 전화번호는 SmsChannelDriver 가
+ * data 에서 해석한다.
+ *
+ * @param array> $channels 현재 채널 메타 목록
+ * @return array> sms·alimtalk 이 추가된 목록
+ */
+ public function addChannels(array $channels): array
+ {
+ $existingIds = array_column($channels, 'id');
+
+ // 검수(테스트) 모드 여부를 채널 메타에 실어 프론트(availableChannels)로 전달한다.
+ // 알림톡·SMS 탭 상단 상태 배너가 이 값으로 "테스트 모드 — 실제 발송 안 됨"을 노출한다.
+ // 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다.
+ $isTestMode = $this->isTestMode();
+
+ foreach ($this->channelMetas() as $meta) {
+ if (! in_array($meta['id'], $existingIds, true)) {
+ $meta['is_test_mode'] = $isTestMode;
+ $channels[] = $meta;
+ }
+ }
+
+ return $channels;
+ }
+
+ /**
+ * 채널 준비 상태를 검사합니다 (core.notification.channel_readiness).
+ *
+ * 코어/타 플러그인 채널의 판정({ready,reason})은 그대로 통과시키고, 우리 채널(sms/alimtalk)
+ * 일 때만 환경설정 충족 여부로 교체한다(D2).
+ *
+ * - sms: bizppurio_id + password + sender_number
+ * - alimtalk: sms 조건 + api_key + sender_key
+ *
+ * @param array{ready: bool, reason: string|null} $result 이전 필터까지의 판정
+ * @param string $channelId 검사 대상 채널
+ * @return array{ready: bool, reason: string|null}
+ */
+ public function checkReadiness(array $result, string $channelId): array
+ {
+ return match ($channelId) {
+ 'sms' => $this->checkSmsReadiness(),
+ 'alimtalk' => $this->checkAlimtalkReadiness(),
+ default => $result,
+ };
+ }
+
+ /**
+ * 우리 채널(sms/alimtalk)의 "미저장=비활성(OFF)" 정책을 적용합니다
+ * (core.notification.channel_enabled 필터).
+ *
+ * 코어의 기본 판정은 "채널 설정 엔트리가 없으면 활성(true)" 이다(하위호환). 하지만
+ * sms·alimtalk 은 플러그인이 나중에 주입한 채널이라, 관리자가 명시적으로 켜기 전에는
+ * 발송·기록하지 않아야 한다(opt-in). 게스트 발송 정책 isChannelGuestAllowed 의
+ * "확장 채널은 미선언=차단" 과 같은 방향이다.
+ *
+ * 따라서 우리 채널이면서 해당 확장의 notifications.channels 저장소에 엔트리가 없을 때만
+ * false 로 덮어쓴다. 엔트리가 저장되어 있으면(켜짐/꺼짐 모두) 코어가 이미 그 값을 반영해
+ * $enabled 로 넘겨주므로 그대로 통과시킨다. 우리 채널이 아니면 항상 원본을 통과시킨다.
+ *
+ * @param bool $enabled 코어가 계산한 활성 여부
+ * @param string $extensionType 확장 타입 (core/module/plugin)
+ * @param string|null $extensionIdentifier 확장 식별자
+ * @param string $channelId 채널 식별자
+ * @return bool 최종 활성 여부
+ */
+ public function gateChannelEnabled(
+ bool $enabled,
+ string $extensionType,
+ ?string $extensionIdentifier,
+ string $channelId
+ ): bool {
+ if (! in_array($channelId, self::CHANNEL_IDS, true)) {
+ return $enabled;
+ }
+
+ // 저장 엔트리가 있으면 코어 판정($enabled)을 존중, 없으면 미저장 → OFF
+ if ($this->hasSavedChannelEntry($extensionType, $extensionIdentifier, $channelId)) {
+ return $enabled;
+ }
+
+ return false;
+ }
+
+ /**
+ * 해당 확장의 notifications.channels 저장소에 특정 채널 엔트리가 존재하는지 확인합니다.
+ *
+ * 저장소는 확장 타입별로 분리됩니다:
+ * - core → SettingsService
+ * - module → ModuleSettingsService
+ * - plugin → PluginSettingsService
+ *
+ * 조회 실패(예외)나 미지원 타입은 "미저장"(false)으로 간주해 안전측(OFF)으로 처리한다.
+ *
+ * @param string $extensionType 확장 타입
+ * @param string|null $extensionIdentifier 확장 식별자
+ * @param string $channelId 채널 식별자
+ * @return bool 저장 엔트리 존재 여부
+ */
+ private function hasSavedChannelEntry(
+ string $extensionType,
+ ?string $extensionIdentifier,
+ string $channelId
+ ): bool {
+ try {
+ $channels = match ($extensionType) {
+ 'core' => app(SettingsService::class)
+ ->getSetting('notifications.channels', []),
+ 'module' => empty($extensionIdentifier)
+ ? []
+ : app(ModuleSettingsService::class)
+ ->get($extensionIdentifier, 'notifications.channels', []),
+ 'plugin' => empty($extensionIdentifier)
+ ? []
+ : app(PluginSettingsService::class)
+ ->get($extensionIdentifier, 'notifications.channels', []),
+ default => [],
+ };
+ } catch (\Throwable $e) {
+ return false;
+ }
+
+ if (! is_array($channels)) {
+ return false;
+ }
+
+ foreach ($channels as $entry) {
+ if (is_array($entry) && ($entry['id'] ?? null) === $channelId) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 레거시 다채널 자동 결정 경로에서 채널 후보에 sms·alimtalk 을 더합니다.
+ *
+ * {prefix}.notification.channels 는 채널 미지정(다채널) 발송 경로에서만 발화하며(코어 확인),
+ * 채널 지정 경로에서는 filter_available_channels 가 SSoT 다. 두 경로 모두에서 채널이
+ * 누락되지 않도록 후보에 우리 채널 id 를 더한다(중복 제거).
+ *
+ * @param array $channels 정의별 채널 id 후보
+ * @param string $type 알림 정의 유형 (미사용)
+ * @param object|null $notifiable 수신자 (미사용)
+ * @return array 우리 채널이 더해진 후보
+ */
+ public function addChannelCandidates(array $channels, string $type = '', ?object $notifiable = null): array
+ {
+ foreach (self::CHANNEL_IDS as $id) {
+ if (! in_array($id, $channels, true)) {
+ $channels[] = $id;
+ }
+ }
+
+ return array_values($channels);
+ }
+
+ /**
+ * sms·alimtalk 채널 메타 정의를 반환합니다.
+ *
+ * @return array>
+ */
+ private function channelMetas(): array
+ {
+ return [
+ [
+ 'id' => 'sms',
+ 'name_key' => self::LANG.'.channels.sms.name',
+ 'description_key' => self::LANG.'.channels.sms.description',
+ 'icon' => 'fas fa-comment-sms',
+ 'source' => self::PLUGIN_IDENTIFIER,
+ 'source_label_key' => self::LANG.'.channels.source_label',
+ 'allow_guest' => true,
+ ],
+ [
+ 'id' => 'alimtalk',
+ 'name_key' => self::LANG.'.channels.alimtalk.name',
+ 'description_key' => self::LANG.'.channels.alimtalk.description',
+ 'icon' => 'fas fa-comment-dots',
+ 'source' => self::PLUGIN_IDENTIFIER,
+ 'source_label_key' => self::LANG.'.channels.source_label',
+ 'allow_guest' => true,
+ ],
+ ];
+ }
+
+ /**
+ * SMS 채널 준비 상태를 검사합니다.
+ *
+ * @return array{ready: bool, reason: string|null}
+ */
+ private function checkSmsReadiness(): array
+ {
+ if ($this->missing('bizppurio_id') || $this->missing('password')) {
+ return $this->notReady('sms_credentials_missing');
+ }
+
+ if ($this->missing('sender_number')) {
+ return $this->notReady('sms_sender_number_missing');
+ }
+
+ return ['ready' => true, 'reason' => null];
+ }
+
+ /**
+ * 알림톡 채널 준비 상태를 검사합니다.
+ *
+ * @return array{ready: bool, reason: string|null}
+ */
+ private function checkAlimtalkReadiness(): array
+ {
+ if ($this->missing('bizppurio_id') || $this->missing('password')) {
+ return $this->notReady('sms_credentials_missing');
+ }
+
+ if ($this->missing('sender_number')) {
+ return $this->notReady('sms_sender_number_missing');
+ }
+
+ if ($this->missing('api_key')) {
+ return $this->notReady('alimtalk_api_key_missing');
+ }
+
+ if ($this->missing('sender_key')) {
+ return $this->notReady('alimtalk_sender_key_missing');
+ }
+
+ return ['ready' => true, 'reason' => null];
+ }
+
+ /**
+ * 환경설정 값이 비어 있는지 확인합니다.
+ *
+ * @param string $key 설정 키
+ * @return bool 비어 있으면 true
+ */
+ private function missing(string $key): bool
+ {
+ $value = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, $key, '');
+
+ return trim((string) $value) === '';
+ }
+
+ /**
+ * 검수(테스트) 모드 여부를 반환합니다.
+ *
+ * is_test_mode=true 이면 실제 발송이 이뤄지지 않는다(검수 환경). 상태 배너 노출 기준.
+ *
+ * @return bool 검수 모드면 true
+ */
+ private function isTestMode(): bool
+ {
+ return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true);
+ }
+
+ /**
+ * ready=false 판정을 lang reason key 와 함께 반환합니다.
+ *
+ * @param string $reasonKey messages.readiness.* 하위 키
+ * @return array{ready: bool, reason: string}
+ */
+ private function notReady(string $reasonKey): array
+ {
+ return ['ready' => false, 'reason' => self::LANG.'.readiness.'.$reasonKey];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php
new file mode 100644
index 00000000..f0455c70
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php
@@ -0,0 +1,260 @@
+ [...], ...]` (키가 type)
+ * - 모듈: 순차 배열 `[['type' => 'order_confirmed', ...], ...]`
+ * 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다.
+ *
+ * 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은
+ * 문자/알림톡 대상이 아니므로 건너뛴다. 기본 body 는 그 알림의 database 채널 body(짧은 평문)를
+ * 재활용하고, 없으면 mail body 의 HTML 을 제거해 만든다.
+ */
+class SeedChannelTemplatesListener implements HookListenerInterface
+{
+ /** 증강할 채널 id 목록 */
+ private const CHANNELS = ['sms', 'alimtalk'];
+
+ /**
+ * 구독할 훅 목록 반환.
+ *
+ * @return array>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ $hooks = [];
+ foreach ([
+ 'seed.notifications.translations',
+ 'seed.sirsoft-board.notifications.translations',
+ 'seed.sirsoft-ecommerce.notifications.translations',
+ ] as $hook) {
+ $hooks[$hook] = [
+ 'method' => 'augment',
+ 'priority' => 50,
+ 'type' => 'filter',
+ ];
+ }
+
+ return $hooks;
+ }
+
+ /**
+ * 기본 핸들러 (미사용 — 필터 메서드로 처리).
+ *
+ * @param mixed ...$args
+ */
+ public function handle(...$args): void {}
+
+ /**
+ * 정의 배열의 각 회원 대상 알림에 sms·alimtalk 채널 template 을 증강합니다.
+ *
+ * 키(코어=type, 모듈=정수)와 순서를 보존한 채 각 정의 값만 변형한다. 정의 형태가 배열이
+ * 아니거나 회원 대상이 아니면 원본을 그대로 둔다.
+ *
+ * @param array $definitions 시딩 직전 알림 정의 배열
+ * @return array 채널이 증강된 정의 배열
+ */
+ public function augment(array $definitions): array
+ {
+ foreach ($definitions as $key => $definition) {
+ if (! is_array($definition) || ! $this->isMemberFacing($definition)) {
+ continue;
+ }
+
+ $definitions[$key] = $this->augmentDefinition($definition);
+ }
+
+ return $definitions;
+ }
+
+ /**
+ * 단일 알림 정의에 sms·alimtalk 채널을 channels·templates 에 추가합니다.
+ *
+ * 이미 해당 채널이 선언돼 있으면(다른 경로로 이미 존재) 중복 추가하지 않는다.
+ *
+ * @param array $definition 단일 알림 정의
+ * @return array 증강된 정의
+ */
+ private function augmentDefinition(array $definition): array
+ {
+ $channels = $definition['channels'] ?? ['mail'];
+ $templates = $definition['templates'] ?? [];
+
+ $existingChannels = array_column($templates, 'channel');
+ $baseBody = $this->resolveBaseBody($templates);
+ $baseSubject = $this->resolveBaseSubject($templates);
+ $recipients = $this->resolveRecipients($templates);
+
+ foreach (self::CHANNELS as $channel) {
+ if (in_array($channel, $existingChannels, true)) {
+ continue;
+ }
+
+ if (! in_array($channel, $channels, true)) {
+ $channels[] = $channel;
+ }
+
+ $templates[] = [
+ 'channel' => $channel,
+ 'recipients' => $recipients,
+ 'subject' => $baseSubject,
+ 'body' => $baseBody,
+ ];
+ }
+
+ $definition['channels'] = $channels;
+ $definition['templates'] = $templates;
+
+ return $definition;
+ }
+
+ /**
+ * 알림이 회원(사용자) 대상인지 판정합니다 (결정 E).
+ *
+ * 정의의 어떤 template recipients 든 관리자 전용(type=role, value=admin)이 아닌 수신자가
+ * 하나라도 있으면 회원 대상으로 본다. recipients 가 전혀 없으면(수신자 미지정) 회원 발송
+ * 기본값으로 포함한다. channels 컬럼이 아니라 recipients 로 판정한다(결정 E).
+ *
+ * @param array $definition 단일 알림 정의
+ * @return bool 회원 대상이면 true
+ */
+ private function isMemberFacing(array $definition): bool
+ {
+ $templates = $definition['templates'] ?? [];
+ $hasAnyRecipient = false;
+
+ foreach ($templates as $template) {
+ foreach ($template['recipients'] ?? [] as $recipient) {
+ $hasAnyRecipient = true;
+
+ $type = $recipient['type'] ?? null;
+ $value = $recipient['value'] ?? null;
+
+ if (! ($type === 'role' && $value === 'admin')) {
+ return true;
+ }
+ }
+ }
+
+ return ! $hasAnyRecipient;
+ }
+
+ /**
+ * 증강할 채널의 기본 body(다국어 배열)를 결정합니다.
+ *
+ * 우선순위: database 채널 body(짧은 평문) → mail body 의 HTML 제거본 → 빈 배열.
+ * 문자/알림톡은 평문이므로 HTML 을 담지 않는다.
+ *
+ * @param array> $templates 기존 template 목록
+ * @return array locale → 평문 body
+ */
+ private function resolveBaseBody(array $templates): array
+ {
+ $database = $this->findTemplate($templates, 'database');
+ if ($database !== null && is_array($database['body'] ?? null)) {
+ return $database['body'];
+ }
+
+ $mail = $this->findTemplate($templates, 'mail');
+ if ($mail !== null && is_array($mail['body'] ?? null)) {
+ return array_map(fn ($body) => $this->stripHtml((string) $body), $mail['body']);
+ }
+
+ return [];
+ }
+
+ /**
+ * 증강할 채널의 기본 subject(다국어 배열)를 결정합니다.
+ *
+ * database → mail subject 순으로 재활용한다. LMS 전환 시 제목으로 쓰인다.
+ *
+ * @param array> $templates 기존 template 목록
+ * @return array locale → subject
+ */
+ private function resolveBaseSubject(array $templates): array
+ {
+ foreach (['database', 'mail'] as $channel) {
+ $template = $this->findTemplate($templates, $channel);
+ if ($template !== null && is_array($template['subject'] ?? null)) {
+ return $template['subject'];
+ }
+ }
+
+ return [];
+ }
+
+ /**
+ * 증강할 채널의 recipients 를 결정합니다.
+ *
+ * 기존 template(database → mail)의 recipients 를 그대로 재활용해 수신자 규칙을 일치시킨다.
+ * 없으면 발생 회원 본인(trigger_user) 기본값을 쓴다.
+ *
+ * @param array> $templates 기존 template 목록
+ * @return array> recipients 규칙
+ */
+ private function resolveRecipients(array $templates): array
+ {
+ foreach (['database', 'mail'] as $channel) {
+ $template = $this->findTemplate($templates, $channel);
+ if ($template !== null && ! empty($template['recipients']) && is_array($template['recipients'])) {
+ return $template['recipients'];
+ }
+ }
+
+ return [['type' => 'trigger_user']];
+ }
+
+ /**
+ * template 목록에서 특정 채널의 template 을 찾습니다.
+ *
+ * @param array> $templates template 목록
+ * @param string $channel 찾을 채널
+ * @return array|null 매칭 template 또는 null
+ */
+ private function findTemplate(array $templates, string $channel): ?array
+ {
+ foreach ($templates as $template) {
+ if (($template['channel'] ?? null) === $channel) {
+ return $template;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * HTML 문자열을 문자/알림톡용 평문으로 변환합니다.
+ *
+ * 태그 제거 + 엔티티 디코드 + 연속 공백 정리. 변수 표기({name} 등)는 보존된다.
+ *
+ * @param string $html 원본 HTML
+ * @return string 평문
+ */
+ private function stripHtml(string $html): string
+ {
+ $text = preg_replace('/<[^>]+>/', ' ', $html) ?? '';
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ return trim((string) preg_replace('/\s+/', ' ', $text));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php
new file mode 100644
index 00000000..bd881706
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/ValidateBizppurioSettingsListener.php
@@ -0,0 +1,138 @@
+
+ */
+ private const LIVE_REQUIRED_FIELDS = ['bizppurio_id', 'password', 'sender_number'];
+
+ /**
+ * 구독 훅 메타데이터.
+ *
+ * @return array>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.plugin_settings.update_rules' => [
+ 'method' => 'addLiveModeRules',
+ 'priority' => 10,
+ 'type' => 'filter',
+ 'sync' => true,
+ ],
+ ];
+ }
+
+ /**
+ * 인터페이스 표준 진입점 — getSubscribedHooks 가 method='addLiveModeRules' 를 명시하므로
+ * 이 메서드는 미사용. HookListenerInterface 추상 메서드 충족 목적으로만 정의한다.
+ *
+ * @param mixed ...$args 사용 안 함
+ */
+ public function handle(...$args): void
+ {
+ // no-op — 실제 진입점은 addLiveModeRules() 메서드 (filter 훅)
+ }
+
+ /**
+ * 운영(live) 환경 진입 시 발송 자격증명에 required 규칙을 부여한다.
+ *
+ * @param array $rules 코어가 스키마로 생성한 검증 규칙
+ * @param string $identifier 검증 대상 플러그인 식별자
+ * @return array 조정된 검증 규칙
+ */
+ public function addLiveModeRules(array $rules, string $identifier): array
+ {
+ if ($identifier !== self::IDENTIFIER) {
+ return $rules;
+ }
+
+ // 검수 모드(is_test_mode)가 명시적으로 false(운영) 일 때만 자격증명을 강제한다.
+ // 본 필터 훅(core.plugin_settings.update_rules)은 코어 UpdatePluginSettingsRequest 가
+ // "현재 요청의 is_test_mode 에 따른 조건부 검증 규칙"을 만들도록 발행하는 확장점이므로,
+ // 현재 입력 값 참조가 본질적으로 필요하다 (FormRequest 우회가 아님 — 코어 검증기 자체의 입력).
+ $isTestMode = filter_var(
+ // audit:allow listener-formrequest-bypass reason: 검증 규칙 필터 훅의 의도된 입력 모드 참조
+ request()->input('is_test_mode', true),
+ FILTER_VALIDATE_BOOLEAN,
+ FILTER_NULL_ON_FAILURE
+ );
+
+ if ($isTestMode !== false) {
+ return $rules;
+ }
+
+ // 운영 모드 검증이 확정된 이 시점(HTTP 요청 처리 중)에 현재 로케일로 항목 라벨을 등록한다.
+ // 요청 처리 시점에는 플러그인 lang 네임스페이스가 모두 준비돼 있어 폴백 없이 정확한
+ // 로케일 라벨이 잡힌다.
+ $this->registerLiveCredentialAttributeLabels();
+
+ foreach (self::LIVE_REQUIRED_FIELDS as $field) {
+ $fieldRules = (array) ($rules[$field] ?? []);
+ // 코어가 부여한 nullable 을 제거하고 required 로 강제.
+ $fieldRules = array_values(array_filter(
+ $fieldRules,
+ static fn ($rule) => $rule !== 'nullable'
+ ));
+ if (! in_array('required', $fieldRules, true)) {
+ array_unshift($fieldRules, 'required');
+ }
+ $rules[$field] = $fieldRules;
+ }
+
+ return $rules;
+ }
+
+ /**
+ * 발송 자격증명 필드의 검증 에러 메시지 항목 라벨을 현재 요청 로케일로 등록한다.
+ *
+ * 코어 UpdatePluginSettingsRequest 의 검증 에러 메시지에서 항목 이름이 영문 키(`bizppurio id`)로
+ * 노출되지 않도록, 전역 validation.attributes 에 표시 이름을 런타임 병합한다. 코어 검증기를
+ * 수정하지 않고 Laravel 의 attribute 해석 메커니즘만 활용한다.
+ */
+ private function registerLiveCredentialAttributeLabels(): void
+ {
+ $locale = app()->getLocale();
+
+ // addLines 가 validation 그룹을 attributes 만 든 빈 껍데기로 조기 캐시하면, 표준 Translator
+ // 의 isLoaded 가드로 인해 validation.php 전체(required 등)가 로드되지 않는 회귀가 발생한다.
+ // 따라서 addLines 전에 validation 그룹을 먼저 로드시켜 캐시를 정상으로 채운 뒤 attributes 만 보탠다.
+ Lang::get('validation.required', [], $locale);
+
+ Lang::addLines([
+ 'validation.attributes.bizppurio_id' => __(self::IDENTIFIER.'::messages.settings.bizppurio_id_attribute', [], $locale),
+ 'validation.attributes.password' => __(self::IDENTIFIER.'::messages.settings.password_attribute', [], $locale),
+ 'validation.attributes.sender_number' => __(self::IDENTIFIER.'::messages.settings.sender_number_attribute', [], $locale),
+ ], $locale);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php
new file mode 100644
index 00000000..8d6b251f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioDispatch.php
@@ -0,0 +1,150 @@
+
+ */
+ protected $fillable = [
+ 'refkey',
+ 'messagekey',
+ 'channel',
+ 'media',
+ 'to_number',
+ 'to_name',
+ 'to_user_id',
+ 'content',
+ 'request_payload',
+ 'notification_type',
+ 'notification_log_id',
+ 'status',
+ 'result_code',
+ 'result_message',
+ 'fallback_status',
+ 'source',
+ 'is_test_mode',
+ 'sent_at',
+ 'reported_at',
+ 'raw_payload',
+ ];
+
+ /**
+ * 속성 캐스팅 정의
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'channel' => DispatchChannel::class,
+ 'status' => DispatchStatus::class,
+ 'source' => DispatchSource::class,
+ 'to_user_id' => 'integer',
+ 'notification_log_id' => 'integer',
+ 'is_test_mode' => 'boolean',
+ 'sent_at' => 'datetime',
+ 'reported_at' => 'datetime',
+ 'request_payload' => 'array',
+ 'raw_payload' => 'array',
+ ];
+ }
+
+ /**
+ * 마스킹된 수신 전화번호를 반환합니다 (010-****-5678 형식).
+ *
+ * 가운데 4자리를 `*` 로 가린다. 뒤 4자리·앞 3자리는 노출한다. 전체보기는
+ * 권한(messaging.view)이 있는 상세 화면에서 원본 to_number 로 별도 제공한다.
+ *
+ * @return string 마스킹된 번호
+ */
+ public function getMaskedNumberAttribute(): string
+ {
+ $digits = preg_replace('/[^0-9]/', '', $this->to_number) ?? '';
+ $len = strlen($digits);
+
+ if ($len < 7) {
+ // 너무 짧으면 뒤 절반만 노출
+ return str_repeat('*', (int) ceil($len / 2)).substr($digits, (int) ceil($len / 2));
+ }
+
+ $head = substr($digits, 0, $len - 8 > 0 ? 3 : $len - 4);
+ $tail = substr($digits, -4);
+
+ return $head.'-****-'.$tail;
+ }
+
+ /**
+ * 회원 수신자 관계 (비회원이면 null).
+ *
+ * @return BelongsTo
+ */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'to_user_id');
+ }
+
+ /**
+ * refkey 로 조회하는 스코프 (webhook 매칭).
+ *
+ * @param Builder $query
+ * @param string $refkey 우리 부여 키
+ * @return Builder
+ */
+ public function scopeByRefkey(Builder $query, string $refkey): Builder
+ {
+ return $query->where('refkey', $refkey);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php
new file mode 100644
index 00000000..9ab0a20f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php
@@ -0,0 +1,87 @@
+
+ */
+ protected $fillable = [
+ 'notification_type',
+ 'channel',
+ 'template_code',
+ 'template_name',
+ 'fallback_sms_enabled',
+ 'is_active',
+ ];
+
+ /**
+ * 속성 캐스팅 정의
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'channel' => DispatchChannel::class,
+ 'fallback_sms_enabled' => 'boolean',
+ 'is_active' => 'boolean',
+ ];
+ }
+
+ /**
+ * 활성 연동만 조회하는 스코프.
+ *
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('is_active', true);
+ }
+
+ /**
+ * 알림 유형으로 조회하는 스코프 (발송 시 template_code 해석).
+ *
+ * @param Builder $query
+ * @param string $notificationType 코어 notification_definitions.type
+ * @return Builder
+ */
+ public function scopeByNotificationType(Builder $query, string $notificationType): Builder
+ {
+ return $query->where('notification_type', $notificationType);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php
new file mode 100644
index 00000000..64dd1114
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php
@@ -0,0 +1,130 @@
+
+ */
+ protected array $repositories = [
+ BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class,
+ BizppurioNotificationBindingRepositoryInterface::class => BizppurioNotificationBindingRepository::class,
+ ];
+
+ /**
+ * CacheInterface 가 필요한 서비스 (contextual binding).
+ *
+ * - BizppurioTokenService(Phase 2): 발송 토큰 캐시
+ * - WebhookReportService(Phase 4): 잔액부족 알림 쿨다운(D3 중복 방지)
+ *
+ * @var array
+ */
+ protected array $cacheServices = [
+ BizppurioTokenService::class,
+ WebhookReportService::class,
+ KakaoTemplateContentResolver::class,
+ ];
+
+ /**
+ * 서비스 컨테이너 바인딩을 등록합니다.
+ *
+ * DispatchLinkContext 는 한 발송 사이클(HTTP 요청/큐 잡) 안에서 refkey↔코어 로그 연결을
+ * 잇기 위해 상태를 공유해야 하므로 scoped(요청 단위 싱글턴)로 바인딩한다. 채널 드라이버와
+ * LinkNotificationLogListener 가 같은 인스턴스를 주입받아 refkey 를 주고받는다(A-2).
+ */
+ public function register(): void
+ {
+ parent::register();
+
+ $this->app->scoped(DispatchLinkContext::class);
+ }
+
+ /**
+ * 플러그인 루트 lang 디렉토리를 로드합니다.
+ *
+ * 백엔드 다국어는 lang/{ko,en}/*.php 에 두고 `$this->translationNamespace()`
+ * (= 플러그인 식별자) 네임스페이스로 로드한다.
+ */
+ protected function loadExtensionTranslations(): void
+ {
+ $langPath = dirname($this->getProviderPath(), 2).'/lang';
+
+ if (is_dir($langPath)) {
+ $this->loadTranslationsFrom($langPath, $this->translationNamespace());
+ }
+ }
+
+ /**
+ * 부팅 — 코어 알림 ChannelManager 에 sms 채널 드라이버를 등록합니다.
+ *
+ * 코어 GenericNotification::via() 가 반환하는 'sms' 채널 문자열은 코어에 드라이버가
+ * 없어 그대로 두면 발송 시 "Driver [sms] not supported" 예외가 난다. 코어를 수정하지
+ * 않고 ChannelManager::extend() 로 드라이버를 런타임 등록해, 'sms' 채널 발송이
+ * SmsChannelDriver::send() 로 위임되게 한다. 플러그인 비활성/삭제 시 이 등록도
+ * 사라져 코어가 안전하게 원복된다.
+ *
+ * 알림톡('alimtalk')도 동일하게 AlimtalkChannelDriver 로 위임한다(Phase 6). 관리자가
+ * 알림톡 탭에서 연결한 승인 템플릿(binding)이 있을 때만 실제 발송되고, 미연결 알림은
+ * 드라이버가 조용히 skip 한다.
+ */
+ public function boot(): void
+ {
+ parent::boot();
+
+ // ChannelManager 는 지연 싱글턴(첫 알림 발송 시 해석)이므로 resolving 콜백으로 등록한다.
+ // 다만 다른 코드가 이미 해석해 둔 경우 resolving 이 발화하지 않으므로, 이미 해석돼
+ // 있으면 즉시 등록하여 어느 순서에서도 누락되지 않게 한다.
+ $this->app->resolving(
+ ChannelManager::class,
+ fn (ChannelManager $manager) => $this->registerChannelDrivers($manager),
+ );
+
+ if ($this->app->resolved(ChannelManager::class)) {
+ $this->registerChannelDrivers($this->app->make(ChannelManager::class));
+ }
+ }
+
+ /**
+ * 코어 알림 ChannelManager 에 이 플러그인의 채널 드라이버를 등록합니다.
+ *
+ * - sms: SmsChannelDriver 로 실제 발송 위임(Phase 3).
+ * - alimtalk: AlimtalkChannelDriver 로 실제 발송 위임(Phase 6). 연결된 승인 템플릿
+ * (binding)이 있을 때만 발송하고 미연결 알림은 드라이버가 skip 한다.
+ *
+ * @param ChannelManager $manager 코어 알림 채널 매니저
+ */
+ private function registerChannelDrivers(ChannelManager $manager): void
+ {
+ $manager->extend('sms', fn ($app) => $app->make(SmsChannelDriver::class));
+ $manager->extend('alimtalk', fn ($app) => $app->make(AlimtalkChannelDriver::class));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php
new file mode 100644
index 00000000..6d2df914
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioDispatchRepository.php
@@ -0,0 +1,161 @@
+ $data 이력 데이터
+ * @return BizppurioDispatch 생성된 이력
+ */
+ public function create(array $data): BizppurioDispatch
+ {
+ return BizppurioDispatch::create($data);
+ }
+
+ /**
+ * refkey 로 발송 이력을 조회합니다.
+ *
+ * @param string $refkey 우리 부여 키
+ * @return BizppurioDispatch|null 매칭된 이력 또는 null
+ */
+ public function findByRefkey(string $refkey): ?BizppurioDispatch
+ {
+ return BizppurioDispatch::query()->byRefkey($refkey)->first();
+ }
+
+ /**
+ * 발송 이력의 속성을 갱신합니다.
+ *
+ * @param BizppurioDispatch $dispatch 대상 이력
+ * @param array $data 갱신 데이터
+ * @return BizppurioDispatch 갱신된 이력
+ */
+ public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch
+ {
+ $dispatch->fill($data)->save();
+
+ return $dispatch;
+ }
+
+ /**
+ * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다.
+ *
+ * @param array $filters channel / status / date_from / date_to / keyword
+ * @param int $perPage 페이지당 건수
+ * @return LengthAwarePaginator
+ */
+ public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator
+ {
+ $query = BizppurioDispatch::query();
+
+ if (! empty($filters['channel'])) {
+ $query->where('channel', $filters['channel']);
+ }
+
+ if (! empty($filters['status'])) {
+ $query->where('status', $filters['status']);
+ }
+
+ if (! empty($filters['date_from'])) {
+ $query->where('sent_at', '>=', $filters['date_from']);
+ }
+
+ if (! empty($filters['date_to'])) {
+ $query->where('sent_at', '<=', $filters['date_to']);
+ }
+
+ if (! empty($filters['keyword'])) {
+ $keyword = $filters['keyword'];
+ $query->where(function ($q) use ($keyword) {
+ $q->where('to_number', 'like', "%{$keyword}%")
+ ->orWhere('to_name', 'like', "%{$keyword}%")
+ ->orWhere('refkey', 'like', "%{$keyword}%");
+ });
+ }
+
+ // 발송 이력은 발송할 때마다 쌓여 뒤쪽 페이지가 깊어진다. 지연 조인으로 OFFSET 구간에서는
+ // 키 컬럼만 훑고, 본문·요청/webhook 페이로드 같은 넓은 컬럼은 이번 페이지 행에서만 읽는다.
+ // 정렬 스펙 끝에 키 컬럼이 자동으로 덧붙어 동률(같은 시각 발송) 구간의 전순서도 보장된다.
+ //
+ // columns 를 좁히지 않은 이유: 이 목록을 소비하는 화면이 아직 없어 표시 컬럼 계약이
+ // 확정되지 않았다. OFFSET 구간의 넓은 컬럼 읽기는 지연 조인으로 이미 사라졌으므로,
+ // 컬럼 프루닝은 화면이 생겨 실제 사용 컬럼이 정해질 때 얹는다.
+ return $this->paginateWithDeferredJoin(
+ query: $query,
+ columns: ['*'],
+ sort: [['column' => 'created_at', 'direction' => 'desc']],
+ perPage: $perPage,
+ );
+ }
+
+ /**
+ * refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리).
+ *
+ * @param string $refkey 발송 사이클에서 부여한 refkey
+ * @param int $notificationLogId 코어 notification_logs.id
+ * @return bool 연결 성공 여부(대상 없으면 false)
+ */
+ public function linkNotificationLog(string $refkey, int $notificationLogId): bool
+ {
+ $dispatch = BizppurioDispatch::query()->byRefkey($refkey)->first();
+
+ if ($dispatch === null) {
+ return false;
+ }
+
+ $dispatch->notification_log_id = $notificationLogId;
+ $dispatch->save();
+
+ return true;
+ }
+
+ /**
+ * 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시).
+ *
+ * @param array $notificationLogIds 코어 로그 id 목록
+ * @return Collection notification_log_id 를 키로 하는 dispatch 맵
+ */
+ public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection
+ {
+ if ($notificationLogIds === []) {
+ return collect();
+ }
+
+ return BizppurioDispatch::query()
+ ->whereIn('notification_log_id', $notificationLogIds)
+ ->get()
+ ->keyBy('notification_log_id');
+ }
+
+ /**
+ * 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
+ *
+ * @param int $limit 최근 dispatch 조회 상한
+ * @return Collection notification_log_id 를 키로 하는 dispatch 맵
+ */
+ public function recentLinkedKeyed(int $limit = 1000): Collection
+ {
+ return BizppurioDispatch::query()
+ ->whereNotNull('notification_log_id')
+ ->orderByDesc('id')
+ ->limit($limit)
+ ->get()
+ ->keyBy('notification_log_id');
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php
new file mode 100644
index 00000000..ad1deff0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php
@@ -0,0 +1,79 @@
+active()
+ ->byNotificationType($notificationType)
+ ->where('channel', $channel)
+ ->first();
+ }
+
+ /**
+ * 채널의 모든 연동을 조회합니다.
+ *
+ * @param string $channel 채널
+ * @return Collection
+ */
+ public function allByChannel(string $channel = 'alimtalk'): Collection
+ {
+ return BizppurioNotificationBinding::query()
+ ->where('channel', $channel)
+ ->get();
+ }
+
+ /**
+ * 알림 유형+채널 연동을 생성하거나 갱신합니다.
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ * @param string $channel 채널
+ * @param array $data 갱신 데이터
+ * @return BizppurioNotificationBinding 저장된 연동
+ */
+ public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding
+ {
+ $binding = BizppurioNotificationBinding::firstOrNew([
+ 'notification_type' => $notificationType,
+ 'channel' => $channel,
+ ]);
+
+ $binding->fill($data)->save();
+
+ return $binding;
+ }
+
+ /**
+ * 알림 유형+채널 연동을 삭제합니다.
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ * @param string $channel 채널
+ * @return void
+ */
+ public function delete(string $notificationType, string $channel = 'alimtalk'): void
+ {
+ BizppurioNotificationBinding::query()
+ ->byNotificationType($notificationType)
+ ->where('channel', $channel)
+ ->delete();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php
new file mode 100644
index 00000000..6e6935da
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioDispatchRepositoryInterface.php
@@ -0,0 +1,88 @@
+ $data 이력 데이터
+ * @return BizppurioDispatch 생성된 이력
+ */
+ public function create(array $data): BizppurioDispatch;
+
+ /**
+ * refkey 로 발송 이력을 조회합니다 (webhook 매칭).
+ *
+ * @param string $refkey 우리 부여 키
+ * @return BizppurioDispatch|null 매칭된 이력 또는 null(위조)
+ */
+ public function findByRefkey(string $refkey): ?BizppurioDispatch;
+
+ /**
+ * 발송 이력의 속성을 갱신합니다.
+ *
+ * @param BizppurioDispatch $dispatch 대상 이력
+ * @param array $data 갱신 데이터
+ * @return BizppurioDispatch 갱신된 이력
+ */
+ public function update(BizppurioDispatch $dispatch, array $data): BizppurioDispatch;
+
+ /**
+ * 필터·검색 조건으로 발송 이력을 페이지네이션 조회합니다 (이력 화면).
+ *
+ * @param array $filters channel / status / date_from / date_to / keyword
+ * @param int $perPage 페이지당 건수
+ * @return LengthAwarePaginator
+ */
+ public function paginate(array $filters, int $perPage = 20): LengthAwarePaginator;
+
+ /**
+ * refkey 로 dispatch 를 찾아 코어 알림 로그 id 를 연결합니다 (A-2 연결고리).
+ *
+ * LinkNotificationLogListener 가 발송 사이클 직후 코어 로그 id 를 이 dispatch 에 기록할 때
+ * 호출한다. refkey 가 없거나(비-비즈뿌리오 로그) 이미 연결됐으면 아무 것도 하지 않는다.
+ *
+ * @param string $refkey 발송 사이클에서 부여한 refkey
+ * @param int $notificationLogId 코어 notification_logs.id
+ * @return bool 연결 성공 여부(대상 없으면 false)
+ */
+ public function linkNotificationLog(string $refkey, int $notificationLogId): bool;
+
+ /**
+ * 코어 알림 로그 id 목록으로 dispatch 를 일괄 조회해 log id 키 맵으로 반환합니다 (A-2 표시).
+ *
+ * 코어 알림 발송 이력 화면이 현재 페이지의 log id 배열로 비즈뿌리오 결과를 한 번에 조회한다
+ * (N+1 회피). 매칭되지 않는 log id(메일·DB 등 비-비즈뿌리오)는 맵에서 빠진다.
+ *
+ * @param array $notificationLogIds 코어 로그 id 목록
+ * @return Collection notification_log_id 를 키로 하는 dispatch 맵
+ */
+ public function findByNotificationLogIdsKeyed(array $notificationLogIds): Collection;
+
+ /**
+ * 코어 로그에 연결된 최근 dispatch 를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
+ *
+ * 화면이 파라미터 없이 GET 으로 최근 결과 맵을 받아 row.id 로 매칭한다(kginicis test-map 선례).
+ * params 로 로그 id 를 넘기지 않으므로 목록 data_source 로드 순서에 의존하지 않는다. 이력 화면은
+ * 페이지네이션(20건)이라 최근 N건 맵으로 현재 화면을 덮는다. notification_log_id 가 없는
+ * dispatch(아직 연결 전)는 제외한다.
+ *
+ * @param int $limit 최근 dispatch 조회 상한
+ * @return Collection notification_log_id 를 키로 하는 dispatch 맵
+ */
+ public function recentLinkedKeyed(int $limit = 1000): Collection;
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php
new file mode 100644
index 00000000..e48b7bf8
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php
@@ -0,0 +1,53 @@
+
+ */
+ public function allByChannel(string $channel = 'alimtalk'): Collection;
+
+ /**
+ * 알림 유형+채널 연동을 생성하거나 갱신합니다 (탭 편집 모달 저장).
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ * @param string $channel 채널
+ * @param array $data template_code / template_name / fallback_sms_enabled / is_active
+ * @return BizppurioNotificationBinding 저장된 연동
+ */
+ public function upsert(string $notificationType, string $channel, array $data): BizppurioNotificationBinding;
+
+ /**
+ * 알림 유형+채널 연동을 삭제합니다 (연동 해제).
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ * @param string $channel 채널
+ * @return void
+ */
+ public function delete(string $notificationType, string $channel = 'alimtalk'): void;
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php
new file mode 100644
index 00000000..1ec99c73
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php
@@ -0,0 +1,301 @@
+definitionService->resolve($type)?->getLocalizedName();
+
+ return $label !== null && $label !== '' ? $label : $type;
+ } catch (\Throwable $e) {
+ return $type;
+ }
+ }
+
+ /**
+ * 알림을 카카오 알림톡으로 발송합니다.
+ *
+ * Laravel NotificationSender 가 'alimtalk' 채널 드라이버로 이 메서드를 호출한다.
+ * GenericNotification 이 아닌 알림은 대상이 아니므로 조용히 무시한다.
+ *
+ * @param object $notifiable 수신자 (User 또는 GuestNotifiable)
+ * @param Notification $notification 발송 대상 알림
+ */
+ public function send(object $notifiable, Notification $notification): void
+ {
+ if (! $notification instanceof GenericNotification) {
+ return;
+ }
+
+ $type = $notification->getType();
+
+ // 1. 이벤트↔알림톡 템플릿 연결 조회 (미연결/비활성이면 알림톡 미발송)
+ // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를
+ // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다.
+ $binding = $this->bindings->findActive($type, DispatchChannel::Alimtalk->value);
+ if ($binding === null) {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_binding_missing', ['type' => $this->resolveTypeLabel($type)])
+ );
+ }
+
+ // 2. 카카오 승인 템플릿 내용(본문·버튼·요소) 조회 — 발송 API 는 완성본을 요구하므로,
+ // templatecode 만으로는 안 되고 카카오에 등록된 실제 내용을 가져와 채운다(B안).
+ // 조회 실패(고아·장애·rate limit)면 알림톡 본문 소스가 없으므로 skip 한다.
+ $kakaoContent = $this->kakaoContent->resolve($binding->template_code);
+ if ($kakaoContent === null) {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_kakao_content_unavailable', ['type' => $this->resolveTypeLabel($type)])
+ );
+ }
+
+ // 3. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone)
+ $to = $this->resolvePhone($notifiable, $notification->getData());
+ if ($to === null) {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)])
+ );
+ }
+
+ // 4. 카카오 내용 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip.
+ $mapped = $this->payloadMapper->map($kakaoContent, $notification->getData());
+ $message = (string) ($mapped['message'] ?? '');
+ if (trim($message) === '') {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)])
+ );
+ }
+
+ // 5. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra 로 전달, 대체발송 ON 시 SMS resend 병합)
+ $refkey = $this->generateRefkey();
+ $payload = $this->payloadBuilder->buildAlimtalk(
+ $to,
+ $binding->template_code,
+ $message,
+ $refkey,
+ (array) ($mapped['extra'] ?? []),
+ );
+
+ if ($binding->fallback_sms_enabled) {
+ $payload = $this->withSmsFallback($payload, $this->smsFallbackBody($type, $notifiable, $notification));
+ }
+
+ // 6. 발송 이력 pending 생성 → Job 위임. Job/webhook 이 refkey 로 조회해 상태 갱신.
+ $this->dispatches->create([
+ 'refkey' => $refkey,
+ 'channel' => DispatchChannel::Alimtalk->value,
+ 'to_number' => $to,
+ 'to_name' => $notifiable->name ?? null,
+ 'to_user_id' => $this->resolveUserId($notifiable),
+ 'content' => $message,
+ 'request_payload' => $this->payloadBuilder->forHistory($payload),
+ 'notification_type' => $type,
+ 'status' => DispatchStatus::Pending->value,
+ 'source' => DispatchSource::Auto->value,
+ 'is_test_mode' => $this->isTestMode(),
+ 'sent_at' => now(),
+ ]);
+
+ // A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록
+ // refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다.
+ $this->linkContext->remember($refkey);
+
+ SendMessageJob::dispatch($payload, $refkey);
+ }
+
+ /**
+ * SMS 대체발송 본문을 코어 alimtalk 템플릿에서 렌더합니다 (B안).
+ *
+ * 알림톡 본문은 카카오 승인 템플릿에서 오지만, 실패 시 대체할 SMS 본문은 카카오와 무관하므로
+ * 코어 알림 템플릿(alimtalk 채널) 본문을 그대로 재사용한다. 코어 템플릿이 없거나 비활성이면
+ * 빈 문자열을 반환하고, withSmsFallback 이 빈 본문이면 대체를 병합하지 않는다(엣지 C2).
+ *
+ * @param string $type 알림 유형
+ * @param object $notifiable 수신자
+ * @param GenericNotification $notification 발송 대상 알림
+ * @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열)
+ */
+ private function smsFallbackBody(string $type, object $notifiable, GenericNotification $notification): string
+ {
+ $template = $this->templateService->resolve($type, DispatchChannel::Alimtalk->value);
+ if ($template === null || ! $template->is_active) {
+ return '';
+ }
+
+ $locale = BaseNotification::resolveNotifiableLocale($notifiable);
+ $rendered = $template->replaceVariables($notification->getData(), $locale);
+
+ return (string) ($rendered['body'] ?? '');
+ }
+
+ /**
+ * 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송, 계획서 §6-2).
+ *
+ * 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다. 대체 SMS 본문은
+ * 코어 알림 본문(치환 완료 텍스트)을 재사용한다. 부록 C-2 의 `resend:{first:"sms"}` +
+ * `recontent:{sms:{message}}` 구조를 따른다. 대체 본문이 비어 있으면(코어 템플릿 부재)
+ * 빈 SMS 를 보내지 않도록 병합하지 않는다(엣지 C2).
+ *
+ * @param array $payload 알림톡 발송 payload
+ * @param string $renderedBody 치환 완료된 코어 본문(대체 SMS 내용)
+ * @return array resend/recontent 가 병합된 payload (빈 본문이면 원본 그대로)
+ */
+ private function withSmsFallback(array $payload, string $renderedBody): array
+ {
+ if (trim($renderedBody) === '') {
+ return $payload;
+ }
+
+ $payload['resend'] = ['first' => 'sms'];
+ $payload['recontent'] = ['sms' => ['message' => $renderedBody]];
+
+ return $payload;
+ }
+
+ /**
+ * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다.
+ *
+ * @param object $notifiable 수신자
+ * @return int|null 회원 ID 또는 null
+ */
+ private function resolveUserId(object $notifiable): ?int
+ {
+ if ($notifiable instanceof User) {
+ return (int) $notifiable->getKey();
+ }
+
+ return null;
+ }
+
+ /**
+ * 수신자의 전화번호를 해석합니다 (SmsChannelDriver 와 동일 규칙).
+ *
+ * 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다.
+ * 숫자 외 문자는 제거하고, 값이 없으면 null 을 반환한다.
+ *
+ * @param object $notifiable 수신자
+ * @param array $data 알림 data
+ * @return string|null 정규화된 전화번호 또는 null
+ */
+ private function resolvePhone(object $notifiable, array $data): ?string
+ {
+ $raw = $notifiable->mobile
+ ?? ($data[self::RECIPIENT_PHONE_KEY] ?? null);
+
+ $normalized = preg_replace('/[^0-9]/', '', (string) $raw);
+
+ return ($normalized === null || $normalized === '') ? null : $normalized;
+ }
+
+ /**
+ * webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다.
+ *
+ * @return string 32자 refkey
+ */
+ private function generateRefkey(): string
+ {
+ return Str::random(32);
+ }
+
+ /**
+ * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용).
+ *
+ * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책).
+ *
+ * @return bool 검수 모드면 true
+ */
+ private function isTestMode(): bool
+ {
+ return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php
new file mode 100644
index 00000000..754f3596
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php
@@ -0,0 +1,297 @@
+
+ */
+ private const LINK_FIELD_MAP = [
+ 'linkMo' => 'url_mobile',
+ 'linkPc' => 'url_pc',
+ 'linkAnd' => 'scheme_android',
+ 'linkIos' => 'scheme_ios',
+ ];
+
+ /**
+ * 카카오 상세조회 응답을 발송 API content.at 형식으로 변환합니다.
+ *
+ * @param array $detail 카카오 상세조회 응답(templateContent/buttons/…)
+ * @param array $data 알림 data (변수 치환 소스: {key => value})
+ * @return array{message: string, extra: array} 치환 완료 본문 + extra
+ */
+ public function map(array $detail, array $data): array
+ {
+ $message = $this->substitute((string) ($detail['templateContent'] ?? ''), $data);
+
+ $extra = [];
+
+ if ($button = $this->mapButtons($detail['buttons'] ?? null, $data)) {
+ $extra['button'] = $button;
+ }
+
+ if ($quickreply = $this->mapButtons($detail['quickReplies'] ?? null, $data)) {
+ $extra['quickreply'] = $quickreply;
+ }
+
+ if ($title = $this->substitute((string) ($detail['templateTitle'] ?? ''), $data)) {
+ $extra['title'] = $title;
+ }
+
+ if ($header = $this->substitute((string) ($detail['templateHeader'] ?? ''), $data)) {
+ $extra['header'] = $header;
+ }
+
+ if ($item = $this->mapItem($detail['templateItem'] ?? null, $data)) {
+ $extra['item'] = $item;
+ }
+
+ if ($highlight = $this->mapItemHighlight($detail['templateItemHighlight'] ?? null, $data)) {
+ $extra['itemhighlight'] = $highlight;
+ }
+
+ if ($link = $this->mapLinkFields($detail['templateRepresentLink'] ?? null, $data)) {
+ $extra['link'] = $link;
+ }
+
+ return ['message' => $message, 'extra' => $extra];
+ }
+
+ /**
+ * 버튼/바로연결 배열을 발송 형식으로 변환합니다 (동일 규칙 공유).
+ *
+ * @param mixed $buttons 카카오 buttons/quickReplies 배열
+ * @param array $data 변수 치환 소스
+ * @return array> 발송 button/quickreply 배열 (없으면 빈 배열)
+ */
+ private function mapButtons(mixed $buttons, array $data): array
+ {
+ if (! is_array($buttons) || $buttons === []) {
+ return [];
+ }
+
+ $mapped = [];
+
+ foreach ($buttons as $button) {
+ if (! is_array($button)) {
+ continue;
+ }
+
+ $row = [];
+
+ if (isset($button['name'])) {
+ $row['name'] = $this->substitute((string) $button['name'], $data);
+ }
+
+ if (isset($button['linkType'])) {
+ $row['type'] = (string) $button['linkType'];
+ }
+
+ // 링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 필드 + URL 변수 치환.
+ foreach (self::LINK_FIELD_MAP as $from => $to) {
+ if (isset($button[$from]) && $button[$from] !== '') {
+ $row[$to] = $this->substituteLinkField((string) $button[$from], $data);
+ }
+ }
+
+ // 전화·플러그인 등 부가 필드.
+ if (isset($button['telNumber']) && $button['telNumber'] !== '') {
+ $row['tel_number'] = (string) $button['telNumber'];
+ }
+
+ if (isset($button['pluginId']) && $button['pluginId'] !== '') {
+ $row['plugin_id'] = (string) $button['pluginId'];
+ }
+
+ if ($row !== []) {
+ $mapped[] = $row;
+ }
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * 대표링크/링크 필드(linkMo/linkPc/linkAnd/linkIos) → 발송 형식으로 변환합니다.
+ *
+ * @param mixed $link 카카오 templateRepresentLink
+ * @param array $data 변수 치환 소스
+ * @return array 발송 link 필드 (없으면 빈 배열)
+ */
+ private function mapLinkFields(mixed $link, array $data): array
+ {
+ if (! is_array($link) || $link === []) {
+ return [];
+ }
+
+ $mapped = [];
+
+ foreach (self::LINK_FIELD_MAP as $from => $to) {
+ if (isset($link[$from]) && $link[$from] !== '') {
+ $mapped[$to] = $this->substituteLinkField((string) $link[$from], $data);
+ }
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * 아이템리스트(list/summary)를 변환하고 각 필드를 치환합니다.
+ *
+ * @param mixed $item 카카오 templateItem
+ * @param array $data 변수 치환 소스
+ * @return array 발송 item (없으면 빈 배열)
+ */
+ private function mapItem(mixed $item, array $data): array
+ {
+ if (! is_array($item) || $item === []) {
+ return [];
+ }
+
+ $mapped = [];
+
+ if (isset($item['list']) && is_array($item['list'])) {
+ $list = [];
+ foreach ($item['list'] as $entry) {
+ if (! is_array($entry)) {
+ continue;
+ }
+ $list[] = [
+ 'title' => $this->substitute((string) ($entry['title'] ?? ''), $data),
+ 'description' => $this->substitute((string) ($entry['description'] ?? ''), $data),
+ ];
+ }
+ if ($list !== []) {
+ $mapped['list'] = $list;
+ }
+ }
+
+ if (isset($item['summary']) && is_array($item['summary'])) {
+ $mapped['summary'] = [
+ 'title' => $this->substitute((string) ($item['summary']['title'] ?? ''), $data),
+ 'description' => $this->substitute((string) ($item['summary']['description'] ?? ''), $data),
+ ];
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * 아이템 하이라이트(title/description)를 변환하고 치환합니다.
+ *
+ * @param mixed $highlight 카카오 templateItemHighlight
+ * @param array $data 변수 치환 소스
+ * @return array 발송 itemhighlight (없으면 빈 배열)
+ */
+ private function mapItemHighlight(mixed $highlight, array $data): array
+ {
+ if (! is_array($highlight) || $highlight === []) {
+ return [];
+ }
+
+ $mapped = [];
+
+ if (isset($highlight['title'])) {
+ $mapped['title'] = $this->substitute((string) $highlight['title'], $data);
+ }
+
+ if (isset($highlight['description'])) {
+ $mapped['description'] = $this->substitute((string) $highlight['description'], $data);
+ }
+
+ return $mapped;
+ }
+
+ /**
+ * 카카오 변수(#{key})를 알림 data 값으로 치환합니다.
+ *
+ * 카카오 템플릿 변수와 코어 알림 data 는 변수명 규칙이 동일하다(표기만 #{} vs {}).
+ * data 에 없는 변수는 원문(#{key})을 유지한다 — 카카오가 변수 불일치로 판단하게 두어,
+ * 우리가 임의로 빈 값을 채워 잘못된 발송을 하지 않는다.
+ *
+ * @param string $text 치환 대상(#{key} 포함)
+ * @param array $data 변수 치환 소스
+ * @return string 치환된 문자열
+ */
+ private function substitute(string $text, array $data): string
+ {
+ if ($text === '' || $data === []) {
+ return $text;
+ }
+
+ $replacements = [];
+ foreach ($data as $key => $value) {
+ if (is_scalar($value) || $value === null) {
+ $replacements['#{'.$key.'}'] = (string) $value;
+ }
+ }
+
+ return strtr($text, $replacements);
+ }
+
+ /**
+ * 버튼/링크 URL 필드를 치환하고, 프로토콜 중복을 방어합니다.
+ *
+ * 코어 알림 data 의 `*_url` 계열 변수(action_url 등)는 항상 `config('app.url')` 기반의
+ * 프로토콜 포함 완전한 URL 이다(mail 채널이 href="{action_url}" 로 원문 그대로 소비하는
+ * 계약). 반면 카카오 알림톡 버튼은 `http://#{action_url}` 처럼 원문에 프로토콜 접두어를
+ * 직접 붙여 등록되는 경우가 있어, 단순 문자열 치환 시 `http://https://…` 형태로 프로토콜이
+ * 중복될 수 있다.
+ *
+ * 원문이 `http://`/`https://` 로 시작 + 치환 결과가 (그 접두어를 제거했을 때) 다시
+ * `http://`/`https://` 로 시작하는 경우에만 원문의 선행 접두어를 제거한다. 변수가 이미
+ * 프로토콜 없이 등록된 경우(가이드 문서 원안)나, 변수값 자체가 프로토콜을 포함하지 않는
+ * 경우(상대경로 등)는 원문을 그대로 둔다 — 후자를 건드리면 오히려 필요한 접두어가 사라진다.
+ *
+ * @param string $text 치환 대상(#{key} 포함, 링크 필드 원문)
+ * @param array $data 변수 치환 소스
+ * @return string 치환되고 프로토콜 중복이 제거된 문자열
+ */
+ private function substituteLinkField(string $text, array $data): string
+ {
+ $substituted = $this->substitute($text, $data);
+
+ foreach (['http://', 'https://'] as $prefix) {
+ if (! str_starts_with($text, $prefix)) {
+ continue;
+ }
+
+ $afterPrefix = substr($substituted, strlen($prefix));
+ if (str_starts_with($afterPrefix, 'http://') || str_starts_with($afterPrefix, 'https://')) {
+ return $afterPrefix;
+ }
+ }
+
+ return $substituted;
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php
new file mode 100644
index 00000000..1280b8b5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkTemplateService.php
@@ -0,0 +1,273 @@
+ lang key, 'variant' => 배지 색].
+ * variant 는 프론트가 배지 색상 클래스로 사용한다(green/yellow/red/gray/dark/purple).
+ *
+ * @var array
+ */
+ 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'],
+ ];
+
+ /**
+ * @param BizppurioKakaoApiClient $kakao 카카오 관리 API 클라이언트
+ * @param PluginSettingsService $pluginSettings 환경설정 조회(sender_key)
+ */
+ public function __construct(
+ private readonly BizppurioKakaoApiClient $kakao,
+ private readonly PluginSettingsService $pluginSettings,
+ ) {}
+
+ /**
+ * 알림톡 템플릿 목록을 실시간 조회합니다.
+ *
+ * @param array $filters status(templateStatus)·keyword·page·count
+ * @return array{templates: array>, pagination: array}
+ *
+ * @throws BizppurioApiException 자격증명 미설정·조회 실패 시(결과코드 508 제외)
+ */
+ 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);
+
+ try {
+ $this->assertSuccess($response);
+ } catch (BizppurioApiException $e) {
+ // 508 = "요청한 데이터가 없음"(검색 결과 0건 포함). 카카오는 이 경우를 목록
+ // 조회 실패로 응답하지만, 실제로는 정상적인 빈 결과이므로 예외로 취급하지 않는다.
+ if ($e->getResultCode() === self::NOT_FOUND_CODE) {
+ return [
+ 'templates' => [],
+ 'pagination' => [
+ 'total' => 0,
+ 'total_page' => 1,
+ 'current_page' => $params['page'],
+ 'per_page' => $params['count'],
+ ],
+ ];
+ }
+
+ throw $e;
+ }
+
+ $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 배지·가능 액션이 부가된 템플릿 상세
+ *
+ * @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> [{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'] ?? []));
+ }
+
+ /**
+ * 발신프로필(사용중) 목록을 조회합니다.
+ *
+ * 규격(5.발신프로필관리): `/v3/kakao/profile/use` 응답의 data 는
+ * `{success: [...프로필], fail: [...조회실패]}` 2단 봉투다. 실제 발신프로필 목록은
+ * data.success 배열에 담기므로 그 배열을 반환한다(data 통째 반환 시 success/fail
+ * 껍데기가 소비처에 그대로 노출됨).
+ *
+ * @return array> 발신프로필 목록(data.success)
+ *
+ * @throws BizppurioApiException 자격증명 미설정·조회 실패 시
+ */
+ public function senderProfiles(): array
+ {
+ $response = $this->kakao->getSenderProfiles();
+
+ $this->assertSuccess($response);
+
+ return array_values((array) ($response['data']['success'] ?? []));
+ }
+
+ /**
+ * 템플릿 행에 상태 배지를 부가합니다.
+ *
+ * serviceStatus(목록) 또는 inspectionStatus/status(상세)에서 배지 기준 상태를 도출한다.
+ * RDY/ACT(승인) 상태만 알림 연결 가능하며, 프론트가 배지로 이를 안내한다.
+ *
+ * @param array $row kapi 템플릿 행
+ * @return array 배지가 부가된 행
+ */
+ 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'] = [
+ // 프론트 $t() 가 해석하는 프론트 lang 키 형식(templates.status.*)으로 준다.
+ // 프론트(en/ko.json)에는 이 키만 존재하며, 백엔드 messages.php 네임스페이스
+ // (::messages.template.status.*)는 프론트에 없어 원문이 그대로 노출된다.
+ 'label_key' => 'sirsoft-message_bizppurio.templates.status.'.$badge['label_key'],
+ 'variant' => $badge['variant'],
+ ];
+
+ return $row;
+ }
+
+ /**
+ * 상세 응답에서 serviceStatus 가 없을 때 status/inspectionStatus 로 상태를 추정합니다.
+ *
+ * 상세 조회는 serviceStatus 대신 status(S/A/R)+inspectionStatus(REG/REQ/REJ/APR)를
+ * 내려주므로, 목록과 동일한 배지 체계로 환원한다.
+ *
+ * @param array $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 $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,
+ );
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php
new file mode 100644
index 00000000..d7ffd430
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioApiClient.php
@@ -0,0 +1,153 @@
+ $payload MessagePayloadBuilder 가 조립한 발송 본문
+ * @return array 응답 배열 (code/description/messagekey/refkey)
+ *
+ * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시
+ */
+ public function sendMessage(array $payload): array
+ {
+ $result = $this->postMessage($payload, $this->tokenService->getToken());
+
+ // 토큰·인증 무효 → 재발급 후 1회 재시도
+ if (in_array((string) ($result['code'] ?? ''), self::AUTH_INVALID_CODES, true)) {
+ $result = $this->postMessage($payload, $this->tokenService->refreshToken());
+ }
+
+ return $result;
+ }
+
+ /**
+ * 발송 응답 결과코드가 성공(1000)인지 판정합니다.
+ *
+ * @param array $result sendMessage 응답
+ * @return bool 성공이면 true
+ */
+ public function isSuccess(array $result): bool
+ {
+ return (string) ($result['code'] ?? '') === self::SUCCESS_CODE;
+ }
+
+ /**
+ * 단일 발송 요청을 수행합니다.
+ *
+ * @param array $payload 발송 본문
+ * @param string $token Bearer 액세스 토큰
+ * @return array 응답 배열
+ *
+ * @throws BizppurioApiException HTTP 실패·응답 파싱 실패 시
+ */
+ private function postMessage(array $payload, string $token): array
+ {
+ $response = $this->http()
+ ->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ 'Content-Type' => 'application/json; charset=utf-8',
+ ])
+ ->post($this->baseUrl().'/v3/message', $payload);
+
+ if ($response->failed()) {
+ // 비즈뿌리오는 실패 응답에도 code·description 을 담아준다(명세). 이를 추출해
+ // 예외에 실어, 발송 이력·로그에 "왜 실패했는지"(결과코드+사유)가 남도록 한다.
+ // body 가 없거나 파싱 불가하면 HTTP 상태만 보존한다.
+ $body = $response->json();
+ $code = is_array($body) ? (string) ($body['code'] ?? '') : '';
+ $description = is_array($body) ? (string) ($body['description'] ?? '') : '';
+
+ throw new BizppurioApiException(
+ $description !== ''
+ ? $description
+ : __('sirsoft-message_bizppurio::messages.error.send_failed'),
+ resultCode: $code !== '' ? $code : null,
+ 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;
+ }
+
+ /**
+ * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다.
+ *
+ * @return string 베이스 URL (스킴 포함)
+ */
+ private function baseUrl(): string
+ {
+ // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은
+ // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다.
+ $isTestMode = (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true);
+
+ return $isTestMode ? self::HOST_DEV : self::HOST_LIVE;
+ }
+
+ /**
+ * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다.
+ */
+ private function http(): PendingRequest
+ {
+ return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS)
+ ->timeout(self::REQUEST_TIMEOUT_SECONDS);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php
new file mode 100644
index 00000000..552c4c99
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioKakaoApiClient.php
@@ -0,0 +1,210 @@
+ 응답 배열 (code/message/data)
+ *
+ * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
+ */
+ public function getSenderProfiles(): array
+ {
+ return $this->post('/v3/kakao/profile/use');
+ }
+
+ /**
+ * 알림톡 템플릿 목록을 조회합니다. (`/v3/kakao/template/list`)
+ *
+ * @param string $senderKey 발신프로필 키
+ * @param array $params 추가 조회 파라미터 (count/page/status 등)
+ * @return array 응답 배열 (code/message/data)
+ *
+ * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
+ */
+ public function getTemplateList(string $senderKey, array $params = []): array
+ {
+ return $this->post('/v3/kakao/template/list', array_merge($params, [
+ 'senderKey' => $senderKey,
+ ]));
+ }
+
+ /**
+ * 알림톡 템플릿 상세를 조회합니다. (`/v3/kakao/template/detail`)
+ *
+ * @param string $senderKey 발신프로필 키
+ * @param string $templateCode 템플릿 코드
+ * @return array 응답 배열 (code/message/data)
+ *
+ * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
+ */
+ public function getTemplateDetail(string $senderKey, string $templateCode): array
+ {
+ return $this->post('/v3/kakao/template/detail', [
+ 'senderKey' => $senderKey,
+ 'templateCode' => $templateCode,
+ ]);
+ }
+
+ /**
+ * 카카오 관리 API 의 임의 엔드포인트를 호출합니다.
+ *
+ * Phase 5·6 의 템플릿 CRUD·검수·카테고리 조회 등에서 재사용한다. bizId/apiKey 는
+ * 자동으로 주입되므로 도메인 파라미터만 전달한다.
+ *
+ * @param string $path 엔드포인트 경로 (예: '/v3/kakao/template/add')
+ * @param array $params 요청 파라미터 (bizId/apiKey 제외)
+ * @return array 응답 배열 (code/message/data)
+ *
+ * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
+ */
+ public function request(string $path, array $params = []): array
+ {
+ return $this->post($path, $params);
+ }
+
+ /**
+ * 카카오 관리 API 응답이 성공(200)인지 판정합니다.
+ *
+ * @param array $result 응답 배열
+ * @return bool 성공이면 true
+ */
+ public function isSuccess(array $result): bool
+ {
+ return (string) ($result['code'] ?? '') === self::SUCCESS_CODE;
+ }
+
+ /**
+ * bizId/apiKey 를 주입해 카카오 관리 API 를 POST 호출합니다.
+ *
+ * @param string $path 엔드포인트 경로
+ * @param array $params 요청 파라미터 (bizId/apiKey 제외)
+ * @return array 응답 배열
+ *
+ * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
+ */
+ private function post(string $path, array $params = []): array
+ {
+ [$bizId, $apiKey] = $this->credentials();
+
+ $response = $this->http()
+ ->withHeaders(['Content-Type' => 'application/json; charset=utf-8'])
+ ->post(self::BASE_URL.$path, array_merge($params, [
+ 'bizId' => $bizId,
+ 'apiKey' => $apiKey,
+ ]));
+
+ if ($response->failed()) {
+ throw $this->failureException($response);
+ }
+
+ $result = $response->json();
+
+ if (! is_array($result)) {
+ throw new BizppurioApiException(
+ __('sirsoft-message_bizppurio::messages.error.invalid_response'),
+ httpStatus: $response->status(),
+ );
+ }
+
+ return $result;
+ }
+
+ /**
+ * HTTP 실패 응답에서 카카오가 준 사유(message)와 결과코드(code)를 추출해 예외를 만듭니다.
+ *
+ * 카카오 관리 API 는 실패 시에도 `{code, message}` 봉투를 반환하므로, body 를 파싱해
+ * 운영자에게 실제 사유(접근 불가 IP·반려 사유·계정 오류 등)를 노출한다. body 파싱이
+ * 불가하거나 message 가 비어 있으면 일반 실패 문구로 폴백한다.
+ *
+ * @param Response $response 실패한 HTTP 응답
+ * @return BizppurioApiException 카카오 사유·결과코드·HTTP 상태가 담긴 예외
+ */
+ private function failureException(Response $response): BizppurioApiException
+ {
+ $body = $response->json();
+ $message = is_array($body) ? (string) ($body['message'] ?? '') : '';
+ $code = is_array($body) ? (string) ($body['code'] ?? '') : '';
+
+ return new BizppurioApiException(
+ $message !== ''
+ ? $message
+ : __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
+ resultCode: $code !== '' ? $code : null,
+ httpStatus: $response->status(),
+ );
+ }
+
+ /**
+ * 환경설정에서 bizId(=bizppurio_id)와 apiKey 를 조회합니다.
+ *
+ * @return array{0: string, 1: string} [bizId, apiKey]
+ *
+ * @throws BizppurioApiException 자격증명 미설정 시
+ */
+ private function credentials(): array
+ {
+ $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? [];
+ $bizId = (string) ($settings['bizppurio_id'] ?? '');
+ $apiKey = (string) ($settings['api_key'] ?? '');
+
+ if ($bizId === '' || $apiKey === '') {
+ throw new BizppurioApiException(
+ __('sirsoft-message_bizppurio::messages.error.kakao_credentials_missing'),
+ );
+ }
+
+ return [$bizId, $apiKey];
+ }
+
+ /**
+ * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다.
+ */
+ private function http(): PendingRequest
+ {
+ return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS)
+ ->timeout(self::REQUEST_TIMEOUT_SECONDS);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php
new file mode 100644
index 00000000..0f715758
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTokenService.php
@@ -0,0 +1,209 @@
+cache->remember(
+ self::CACHE_KEY,
+ fn (): string => $this->issueToken(),
+ self::CACHE_TTL_SECONDS,
+ );
+ }
+
+ /**
+ * 캐시된 토큰을 무효화하고 새 토큰을 발급받아 반환합니다.
+ *
+ * 발송 응답이 3002/3005(토큰·인증 무효)일 때 호출한다.
+ *
+ * @return string 새로 발급받은 Bearer 액세스 토큰
+ *
+ * @throws BizppurioApiException 토큰 재발급 실패 시
+ */
+ public function refreshToken(): string
+ {
+ $this->cache->forget(self::CACHE_KEY);
+
+ return $this->getToken();
+ }
+
+ /**
+ * 캐시된 토큰을 제거합니다.
+ */
+ public function forget(): void
+ {
+ $this->cache->forget(self::CACHE_KEY);
+ }
+
+ /**
+ * 현재 저장된 자격증명으로 토큰 발급을 즉시 재검증합니다.
+ *
+ * 캐시를 거치지 않고 매번 `/v1/token` 을 새로 호출한다(관리자가 계정/비밀번호가
+ * 유효한지 그 자리에서 확인하려는 목적 — 설정 화면 "연결 확인" 버튼이 소비).
+ * 검증에 성공하면 새로 발급된 토큰으로 캐시를 갱신해, 확인 직후의 발송이 이
+ * 토큰을 그대로 재사용할 수 있게 한다(불필요한 재발급 방지).
+ *
+ * @return string 새로 발급받은 Bearer 액세스 토큰
+ *
+ * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
+ */
+ public function verifyCredentials(): string
+ {
+ $token = $this->issueToken();
+
+ $this->cache->put(self::CACHE_KEY, $token, self::CACHE_TTL_SECONDS);
+
+ return $token;
+ }
+
+ /**
+ * `/v1/token` 을 호출해 새 토큰을 발급받습니다.
+ *
+ * @return string Bearer 액세스 토큰
+ *
+ * @throws BizppurioApiException 자격증명 미설정·HTTP 실패·응답 파싱 실패 시
+ */
+ private function issueToken(): string
+ {
+ $settings = $this->pluginSettings->get(self::PLUGIN_IDENTIFIER) ?? [];
+ $account = (string) ($settings['bizppurio_id'] ?? '');
+ $password = (string) ($settings['password'] ?? '');
+
+ if ($account === '' || $password === '') {
+ throw new BizppurioApiException(
+ __('sirsoft-message_bizppurio::messages.error.credentials_missing'),
+ );
+ }
+
+ $response = $this->http()
+ ->withHeaders([
+ 'Authorization' => 'Basic '.base64_encode($account.':'.$password),
+ 'Content-Type' => 'application/json; charset=utf-8',
+ ])
+ ->post($this->baseUrl($settings).'/v1/token');
+
+ if ($response->failed()) {
+ throw new BizppurioApiException(
+ $this->describeFailure($response),
+ resultCode: (string) ($response->json('code') ?? '') ?: null,
+ httpStatus: $response->status(),
+ );
+ }
+
+ $token = (string) ($response->json('accesstoken') ?? '');
+
+ if ($token === '') {
+ throw new BizppurioApiException(
+ $this->describeFailure($response),
+ resultCode: (string) ($response->json('code') ?? '') ?: null,
+ httpStatus: $response->status(),
+ );
+ }
+
+ return $token;
+ }
+
+ /**
+ * 토큰 발급 실패 응답에서 비즈뿌리오 원문 사유를 담은 메시지를 만듭니다.
+ *
+ * 비즈뿌리오는 실패 시 `{"code": "3007", "description": "invalid password in
+ * bizppurio"}` 형태로 원인을 내려준다. 원문이 있으면 함께 노출해 운영자가 계정/
+ * 비밀번호 오류인지 서버 오류인지 즉시 구분할 수 있게 한다(고정 문구만으로는
+ * 원인 추적이 불가능했던 문제 대응).
+ *
+ * @param \Illuminate\Http\Client\Response $response 실패한 HTTP 응답
+ * @return string 사용자에게 보여줄 실패 메시지
+ */
+ private function describeFailure(\Illuminate\Http\Client\Response $response): string
+ {
+ $description = (string) ($response->json('description') ?? '');
+
+ if ($description === '') {
+ return __('sirsoft-message_bizppurio::messages.error.token_issue_failed');
+ }
+
+ return __('sirsoft-message_bizppurio::messages.error.token_issue_failed_with_reason', [
+ 'reason' => $description,
+ ]);
+ }
+
+ /**
+ * 환경(운영/검수)에 맞는 발송 도메인 베이스 URL 을 반환합니다.
+ *
+ * @param array $settings 플러그인 환경설정
+ * @return string 베이스 URL (스킴 포함)
+ */
+ private function baseUrl(array $settings): string
+ {
+ // 검수 모드(is_test_mode)가 꺼져 있으면 운영 도메인으로 발송한다. 기본값(미설정)은
+ // 안전하게 검수(true)로 간주해 운영 발송이 우발적으로 일어나지 않도록 한다.
+ $isTestMode = (bool) ($settings['is_test_mode'] ?? true);
+
+ return $isTestMode ? self::HOST_DEV : self::HOST_LIVE;
+ }
+
+ /**
+ * 공통 타임아웃이 적용된 HTTP 클라이언트를 반환합니다.
+ */
+ private function http(): PendingRequest
+ {
+ return Http::connectTimeout(self::CONNECT_TIMEOUT_SECONDS)
+ ->timeout(self::REQUEST_TIMEOUT_SECONDS);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php
new file mode 100644
index 00000000..42fe4fc5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchLinkContext.php
@@ -0,0 +1,55 @@
+pendingRefkey = $refkey;
+ }
+
+ /**
+ * 링크 리스너가 최근 refkey 를 꺼냅니다 (꺼내면 비웁니다).
+ *
+ * 비-비즈뿌리오 채널(mail/database 등)의 로그가 발화한 경우엔 이 사이클에서 remember 된
+ * refkey 가 없으므로 null 을 반환한다(그 로그는 연결 대상 아님).
+ *
+ * @return string|null 최근 refkey 또는 null
+ */
+ public function consume(): ?string
+ {
+ $refkey = $this->pendingRefkey;
+ $this->pendingRefkey = null;
+
+ return $refkey;
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php
new file mode 100644
index 00000000..441379b9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/DispatchResultService.php
@@ -0,0 +1,103 @@
+ $notificationLogIds 코어 로그 id 목록(현재 페이지)
+ * @return array> notification_log_id → 결과 표시 데이터
+ */
+ public function resultsForLogIds(array $notificationLogIds): array
+ {
+ $dispatchMap = $this->dispatches->findByNotificationLogIdsKeyed($notificationLogIds);
+
+ return $this->buildResultMap($dispatchMap);
+ }
+
+ /**
+ * 최근 연결된 dispatch 결과를 log id 키 맵으로 반환합니다 (A-2 표시, 타이밍 무관).
+ *
+ * 화면이 파라미터 없이 조회해 row.id 로 매칭한다(kginicis test-map 선례). 목록 data_source
+ * 로드 순서에 의존하지 않아 결과 컬럼이 빈 배열로 비는 문제를 원천 차단한다.
+ *
+ * @param int $limit 최근 dispatch 조회 상한
+ * @return array> notification_log_id → 결과 표시 데이터
+ */
+ public function recentResults(int $limit = 1000): array
+ {
+ return $this->buildResultMap($this->dispatches->recentLinkedKeyed($limit));
+ }
+
+ /**
+ * dispatch 맵을 log id 키의 결과 표시 배열 맵으로 변환합니다.
+ *
+ * @param \Illuminate\Support\Collection $dispatchMap log id 키 dispatch 맵
+ * @return array> notification_log_id → 결과 표시 데이터
+ */
+ private function buildResultMap(\Illuminate\Support\Collection $dispatchMap): array
+ {
+ $results = [];
+
+ foreach ($dispatchMap as $logId => $dispatch) {
+ $results[(int) $logId] = $this->buildResult($dispatch);
+ }
+
+ return $results;
+ }
+
+ /**
+ * dispatch 1건을 화면 표시용 결과 배열로 변환합니다.
+ *
+ * @param BizppurioDispatch $dispatch 연결된 발송 이력
+ * @return array 상태·결과 라벨·잔액부족·대체발송·검수 모드 표시 데이터
+ */
+ private function buildResult(BizppurioDispatch $dispatch): array
+ {
+ $code = $dispatch->result_code;
+ $hasCode = $code !== null && $code !== '';
+
+ return [
+ 'status' => $dispatch->status?->value,
+ 'status_label' => $dispatch->status?->label(),
+ 'result_code' => $code,
+ // `사유 (코드)` 표시 라벨. 코드가 없으면(리포트 미수신) null → 화면은 상태 라벨만 표시.
+ 'result_label' => $hasCode ? $this->resultCodes->label($code) : null,
+ 'is_low_balance' => $hasCode && $this->resultCodes->isBalanceLow($code),
+ 'fallback_status' => $dispatch->fallback_status,
+ 'channel' => $dispatch->channel?->value,
+ // 발송 시점 검수 모드 스냅샷. null=컬럼 신설 이전 이력(검수 여부 판단 불가 → 화면 미표시).
+ 'is_test_mode' => $dispatch->is_test_mode,
+ // 실제 비즈뿌리오에 발송한 본문. 알림톡은 코어 notification_logs.body(대체발송용 코어
+ // 템플릿 본문)와 다른 값(카카오 승인 템플릿 실제 내용)이라, 코어 "본문" 표시만으로는
+ // 실제 발송 내용을 알 수 없다 — 화면이 채널별로 구분해 별도 노출할 수 있도록 포함.
+ 'content' => $dispatch->content,
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php
new file mode 100644
index 00000000..523d0260
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php
@@ -0,0 +1,193 @@
+0 이면 캐시 우선(없으면 조회 후 캐시), TTL=0 이면 캐시 우회(매번 조회).
+ * 조회 실패는 null 을 반환한다.
+ *
+ * @param string $templateCode 카카오 템플릿 코드
+ * @return array|null 카카오 상세(templateContent/buttons/…) 또는 실패 시 null
+ */
+ public function resolve(string $templateCode): ?array
+ {
+ $ttl = $this->cacheTtl();
+
+ // TTL=0 → 캐시 우회, 매번 실시간 조회.
+ if ($ttl <= 0) {
+ return $this->fetch($templateCode);
+ }
+
+ $cacheKey = self::CACHE_KEY_PREFIX.$templateCode;
+
+ // 캐시 히트면 그대로 반환. 미스면 조회해 캐시.
+ $cached = $this->cache->get($cacheKey);
+ if (is_array($cached)) {
+ return $cached;
+ }
+
+ $content = $this->fetch($templateCode);
+
+ // 조회 실패(null)는 캐시하지 않는다 — 다음 발송에서 재시도 가능하도록.
+ if ($content !== null) {
+ $this->cache->put($cacheKey, $content, $ttl);
+ }
+
+ return $content;
+ }
+
+ /**
+ * 특정 템플릿의 캐시를 무효화합니다 (관리자 수동 초기화).
+ *
+ * @param string $templateCode 카카오 템플릿 코드
+ */
+ public function clear(string $templateCode): void
+ {
+ $this->cache->forget(self::CACHE_KEY_PREFIX.$templateCode);
+ }
+
+ /**
+ * 여러 템플릿의 캐시를 한 번에 무효화합니다 (관리자 전체 초기화).
+ *
+ * 캐시 키가 template_code 단위이므로, 연결된 모든 알림톡 템플릿 코드를 받아 각각 비운다.
+ * 다음 발송에서 최신 내용으로 재조회된다.
+ *
+ * @param array $templateCodes 카카오 템플릿 코드 목록
+ * @return int 비운 캐시 키 수(중복 제거 후)
+ */
+ public function clearMany(array $templateCodes): int
+ {
+ $codes = array_values(array_unique(array_filter($templateCodes, static fn ($c) => $c !== '')));
+
+ foreach ($codes as $code) {
+ $this->clear((string) $code);
+ }
+
+ return count($codes);
+ }
+
+ /**
+ * 카카오 상세조회를 수행합니다. 실패는 null(예외 삼킴 + 로그).
+ *
+ * rate limit(HTTP 429 또는 결과코드 5002)은 조회 폭주 상황이므로 짧게 대기 후 1회
+ * 재시도한다(무한 방지). 재시도해도 rate limit 이면 null 을 반환해 호출측이 발송을 skip
+ * 하게 한다. 그 외 실패(고아 template_code·자격증명 오류 등)는 재시도 없이 null.
+ *
+ * @param string $templateCode 카카오 템플릿 코드
+ * @return array|null 상세 또는 실패 시 null
+ */
+ private function fetch(string $templateCode): ?array
+ {
+ for ($attempt = 0; $attempt <= self::RATE_LIMIT_RETRIES; $attempt++) {
+ try {
+ return $this->templates->detail($templateCode);
+ } catch (BizppurioApiException $e) {
+ // rate limit 이고 재시도 여유가 남았으면 짧게 대기 후 재시도.
+ if ($this->isRateLimited($e) && $attempt < self::RATE_LIMIT_RETRIES) {
+ usleep(self::RATE_LIMIT_RETRY_WAIT_US);
+
+ continue;
+ }
+
+ Log::warning('비즈뿌리오 알림톡 템플릿 내용 조회 실패 — 발송 skip 후보', [
+ 'template_code' => $templateCode,
+ 'result_code' => $e->getResultCode(),
+ 'http_status' => $e->getHttpStatus(),
+ 'message' => $e->getMessage(),
+ ]);
+
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 예외가 rate limit(HTTP 429 또는 결과코드 5002)인지 판정합니다.
+ *
+ * @param BizppurioApiException $e 조회 실패 예외
+ * @return bool rate limit 이면 true
+ */
+ private function isRateLimited(BizppurioApiException $e): bool
+ {
+ return $e->getHttpStatus() === 429 || $e->getResultCode() === '5002';
+ }
+
+ /**
+ * 캐시 TTL(초)을 환경설정에서 조회합니다 (기본 60분, 0=캐시 끔).
+ *
+ * 관리자 화면·저장은 "분" 단위(template_cache_minutes)로 관리하고, 캐시 드라이버에는
+ * 초 단위가 필요하므로 60 을 곱해 반환한다. 0 이면 캐시를 끄므로 0 그대로 반환한다.
+ *
+ * @return int TTL(초)
+ */
+ private function cacheTtl(): int
+ {
+ $minutes = (int) $this->pluginSettings->get(
+ self::PLUGIN_IDENTIFIER,
+ 'template_cache_minutes',
+ self::DEFAULT_CACHE_MINUTES,
+ );
+
+ return max(0, $minutes) * 60;
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php
new file mode 100644
index 00000000..672d0047
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/MessagePayloadBuilder.php
@@ -0,0 +1,174 @@
+ 발송 payload
+ */
+ public function buildSms(string $to, string $message, string $refkey): array
+ {
+ return $this->withCommon('sms', $to, $refkey, [
+ 'sms' => [
+ 'message' => $message,
+ ],
+ ]);
+ }
+
+ /**
+ * LMS 발송 payload 를 조립합니다. (message: EUC-KR 최대 2000byte, subject 최대 64byte)
+ *
+ * @param string $to 수신 전화번호
+ * @param string $message 본문
+ * @param string $refkey 우리 부여 키
+ * @param string|null $subject 제목(코어 subject 재사용). 없으면 생략
+ * @return array 발송 payload
+ */
+ public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array
+ {
+ $lms = ['message' => $message];
+
+ if ($subject !== null && $subject !== '') {
+ $lms['subject'] = $subject;
+ }
+
+ return $this->withCommon('lms', $to, $refkey, [
+ 'lms' => $lms,
+ ]);
+ }
+
+ /**
+ * 알림톡(AT) 발송 payload 를 조립합니다. (message: 한/영 1300자)
+ *
+ * senderkey 는 환경설정 sender_key 를 사용한다. 버튼/바로연결 등 유형별 선택
+ * 필드는 $extra 로 병합한다(템플릿에 포함된 경우 필수 — 채널 드라이버가 구성).
+ *
+ * @param string $to 수신 전화번호
+ * @param string $templateCode 카카오 템플릿 코드
+ * @param string $message 치환 완료된 본문 (#{변수} 형식)
+ * @param string $refkey 우리 부여 키
+ * @param array $extra button/quickreply/title 등 유형별 선택 필드
+ * @return array 발송 payload
+ */
+ public function buildAlimtalk(
+ string $to,
+ string $templateCode,
+ string $message,
+ string $refkey,
+ array $extra = [],
+ ): array {
+ $at = array_merge([
+ 'senderkey' => $this->senderKey(),
+ 'templatecode' => $templateCode,
+ 'message' => $message,
+ ], $extra);
+
+ return $this->withCommon('at', $to, $refkey, [
+ 'at' => $at,
+ ]);
+ }
+
+ /**
+ * 공통 필드(account/type/from/to/refkey)와 채널별 content 를 결합합니다.
+ *
+ * @param string $type 메시지 타입 (sms/lms/at)
+ * @param string $to 수신 전화번호
+ * @param string $refkey 우리 부여 키
+ * @param array $content 채널별 content 배열
+ * @return array 발송 payload
+ */
+ private function withCommon(string $type, string $to, string $refkey, array $content): array
+ {
+ return [
+ 'account' => $this->account(),
+ 'type' => $type,
+ 'from' => $this->senderNumber(),
+ 'to' => $to,
+ 'refkey' => $refkey,
+ 'content' => $content,
+ ];
+ }
+
+ /**
+ * 발송 payload 에서 발송 이력(request_payload) 저장용으로 개인식별 정보를 제거합니다.
+ *
+ * 이력 테이블에 이미 저장되는 필드(to→to_number, refkey→refkey, type→channel,
+ * content.{sms,lms,at}.message→content)는 완전 중복이므로 제외한다. 나머지(account/
+ * from/senderkey/templatecode/button 등 요소·resend/recontent)는 이력 테이블 어디에도
+ * 없고 발송 실패 원인 분석(결함①)·버튼 URL 검증(결함②)에 필요하므로 그대로 남긴다.
+ *
+ * @param array $payload buildSms/buildLms/buildAlimtalk 가 조립한 발송 payload
+ * @return array 개인식별 정보를 제거한 이력 저장용 payload
+ */
+ public function forHistory(array $payload): array
+ {
+ unset($payload['to'], $payload['refkey'], $payload['type']);
+
+ foreach (['sms', 'lms', 'at'] as $channelKey) {
+ if (isset($payload['content'][$channelKey]['message'])) {
+ unset($payload['content'][$channelKey]['message']);
+ }
+ }
+
+ if (isset($payload['recontent']['sms']['message'])) {
+ unset($payload['recontent']['sms']['message']);
+ }
+
+ return $payload;
+ }
+
+ /**
+ * 발송 계정(account = bizppurio_id)을 반환합니다.
+ */
+ private function account(): string
+ {
+ return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'bizppurio_id', '');
+ }
+
+ /**
+ * 발신 번호(from = sender_number)를 반환합니다.
+ */
+ private function senderNumber(): string
+ {
+ return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_number', '');
+ }
+
+ /**
+ * 알림톡 발신프로필 키(senderkey = sender_key)를 반환합니다.
+ */
+ private function senderKey(): string
+ {
+ return (string) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'sender_key', '');
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php
new file mode 100644
index 00000000..f3fd3a19
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php
@@ -0,0 +1,239 @@
+> notification_type 키의 연동 맵
+ */
+ public function all(bool $withAvailability = false): array
+ {
+ // 소실 판정 대상 = 발송 가능한(승인) 카카오 템플릿 코드 집합. 조회 실패(카카오 장애·자격증명
+ // 미설정)면 null 을 반환해 판정 자체를 건너뛴다 — 살아 있는 연동이 일시 장애로 "사용 불가"로
+ // 오탐되어 화면이 전부 경고로 물드는 것을 막는다(발송 시점 판정과 동일한 안전측 기준).
+ $sendableCodes = $withAvailability ? $this->sendableTemplateCodesOrNull() : null;
+
+ return $this->bindings->allByChannel(self::CHANNEL)
+ ->keyBy('notification_type')
+ ->map(function (BizppurioNotificationBinding $binding) use ($sendableCodes) {
+ $info = [
+ 'notification_type' => $binding->notification_type,
+ 'template_code' => $binding->template_code,
+ 'template_name' => $binding->template_name,
+ 'fallback_sms_enabled' => (bool) $binding->fallback_sms_enabled,
+ ];
+
+ // 승인 목록 조회에 성공했을 때만 소실 여부를 부여한다(null=판정 생략).
+ if ($sendableCodes !== null) {
+ $info['is_unavailable'] = ! in_array($binding->template_code, $sendableCodes, true);
+ }
+
+ return $info;
+ })
+ ->all();
+ }
+
+ /**
+ * 발송 가능한(승인) 카카오 템플릿 코드 집합을 반환하되, 조회 실패 시 null 을 반환합니다.
+ *
+ * approvedTemplates() 는 자격증명 미설정·카카오 장애 시 BizppurioApiException 을 던진다. 소실
+ * 판정은 "표시 부가정보"이므로 실패를 삼키고 null 을 반환해, 호출부가 판정을 건너뛰게 한다.
+ *
+ * @return array|null 승인 template_code 목록(성공) 또는 null(조회 실패)
+ */
+ private function sendableTemplateCodesOrNull(): ?array
+ {
+ try {
+ return array_column($this->approvedTemplates(), 'template_code');
+ } catch (BizppurioApiException) {
+ return null;
+ }
+ }
+
+ /**
+ * 연결 가능한(발송 가능/승인) 알림톡 템플릿 목록을 반환합니다 (연동 모달 드롭다운).
+ *
+ * 카카오 템플릿 목록 중 serviceStatus 가 RDY/ACT 인 항목만 노출한다. 미승인 템플릿에
+ * 연동해도 발송이 거부되므로 애초에 선택지에서 제외한다.
+ *
+ * @return array
+ *
+ * @throws BizppurioApiException 자격증명 미설정·조회 실패 시
+ */
+ public function approvedTemplates(): array
+ {
+ // 승인 상태 필터는 kapi 에 status 파라미터로 위임하지 않고(상태 2종 조회 불가), 전체를
+ // 받아 serviceStatus 로 거른다. 페이지네이션 대신 넉넉한 count 로 1회 조회한다.
+ $result = $this->templates->list(['count' => 100]);
+
+ return collect($result['templates'] ?? [])
+ ->filter(fn (array $row) => in_array((string) ($row['serviceStatus'] ?? $row['status'] ?? ''), self::SENDABLE_STATUSES, true))
+ ->map(fn (array $row) => [
+ 'template_code' => (string) ($row['templateCode'] ?? $row['code'] ?? ''),
+ 'template_name' => (string) ($row['templateName'] ?? $row['name'] ?? ''),
+ ])
+ ->filter(fn (array $row) => $row['template_code'] !== '')
+ ->values()
+ ->all();
+ }
+
+ /**
+ * 알림에 알림톡 템플릿을 연결(생성/갱신)합니다 (연동 모달 저장).
+ *
+ * 저장 전 카카오 승인 상태(RDY/ACT)를 재검증한다 — 드롭다운이 승인 템플릿만 보여주지만
+ * 그 필터는 화면 단계일 뿐이라, API 를 직접 호출하면 미승인 템플릿도 저장될 수 있었다(회귀).
+ * 카카오 조회 자체가 실패(장애·자격증명 미설정)하면 승인 여부를 판정할 수 없으므로 안전측으로
+ * 저장을 거부한다 — 조회 실패를 "승인됨"으로 잘못 해석해 미승인 템플릿이 새는 것을 막는다.
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ * @param array $data template_code / template_name / fallback_sms_enabled
+ * @return BizppurioNotificationBinding 저장된 연동
+ *
+ * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
+ */
+ public function bind(string $notificationType, array $data): BizppurioNotificationBinding
+ {
+ $templateCode = (string) $data['template_code'];
+
+ $this->assertSendable($templateCode);
+
+ return $this->bindings->upsert($notificationType, self::CHANNEL, [
+ 'template_code' => $templateCode,
+ 'template_name' => (string) $data['template_name'],
+ 'fallback_sms_enabled' => (bool) ($data['fallback_sms_enabled'] ?? false),
+ 'is_active' => true,
+ ]);
+ }
+
+ /**
+ * 템플릿 코드가 발송 가능(승인) 상태인지 검증합니다. 아니면 예외를 던집니다.
+ *
+ * @param string $templateCode 검증할 카카오 템플릿 코드
+ *
+ * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드
+ */
+ private function assertSendable(string $templateCode): void
+ {
+ $sendableCodes = array_column($this->approvedTemplates(), 'template_code');
+
+ if (! in_array($templateCode, $sendableCodes, true)) {
+ throw new BizppurioApiException(
+ __('sirsoft-message_bizppurio::messages.error.template_not_sendable', ['code' => $templateCode]),
+ );
+ }
+ }
+
+ /**
+ * 알림의 알림톡 연동을 해제(삭제)합니다.
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ */
+ public function unbind(string $notificationType): void
+ {
+ $this->bindings->delete($notificationType, self::CHANNEL);
+ }
+
+ /**
+ * 연결된 모든 알림톡 템플릿의 발송용 내용 캐시를 초기화합니다 (관리자 수동 갱신).
+ *
+ * 카카오에서 템플릿 내용을 방금 바꿔 캐시 만료(기본 1시간)를 기다리지 않고 즉시 반영하고
+ * 싶을 때 사용한다. 연결(binding)된 template_code 를 모아 각 캐시를 비우면 다음 발송에서
+ * 최신 내용으로 재조회된다.
+ *
+ * @return int 초기화한 캐시 키 수(연결된 고유 template_code 수)
+ */
+ public function clearTemplateContentCache(): int
+ {
+ $codes = $this->bindings->allByChannel(self::CHANNEL)
+ ->pluck('template_code')
+ ->all();
+
+ return $this->kakaoContent->clearMany($codes);
+ }
+
+ /**
+ * 연결 모달이 넘긴 값으로 연동을 반영합니다.
+ *
+ * 우리 연결 저장 API(NotificationBindingController::store → POST notification-bindings)에서
+ * 호출된다. 연결 템플릿 코드가 비어 있으면 연동 해제, 있으면 생성/갱신한다. 이 "빈 값=해제"
+ * 규칙 덕분에 연결 모달에서 드롭다운을 "연결 안 함"으로 바꾸고 저장하면 저장 한 번으로
+ * 해제까지 처리된다.
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ * @param string|null $templateCode 연결할 카카오 템플릿 코드 (빈 값=해제)
+ * @param string|null $templateName 템플릿 이름 스냅샷 (고아 감지용)
+ * @param bool $fallbackSmsEnabled 실패 시 SMS 대체발송 여부
+ *
+ * @throws BizppurioApiException 카카오 조회 실패, 또는 미승인·존재하지 않는 템플릿 코드 (해제 시에는 미발생)
+ */
+ public function applyFromTemplateSave(
+ string $notificationType,
+ ?string $templateCode,
+ ?string $templateName,
+ bool $fallbackSmsEnabled,
+ ): void {
+ $code = trim((string) $templateCode);
+
+ if ($code === '') {
+ $this->unbind($notificationType);
+
+ return;
+ }
+
+ $this->bind($notificationType, [
+ 'template_code' => $code,
+ 'template_name' => trim((string) $templateName),
+ 'fallback_sms_enabled' => $fallbackSmsEnabled,
+ ]);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php
new file mode 100644
index 00000000..f854ce79
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/ResultCodeResolver.php
@@ -0,0 +1,122 @@
+categorize($code) === ResultCategory::Success;
+ }
+
+ /**
+ * 결과 코드가 잔액 부족인지 여부 (D3 자체 알림 트리거).
+ *
+ * @param string $code 결과 코드
+ * @return bool
+ */
+ public function isBalanceLow(string $code): bool
+ {
+ return in_array($code, self::BALANCE_LOW_CODES, true);
+ }
+
+ /**
+ * 결과 코드의 사람이 읽는 사유(로케일)를 반환합니다.
+ *
+ * lang 에 정의된 코드면 사유를, 없으면 null 을 반환한다.
+ *
+ * @param string $code 결과 코드
+ * @return string|null 사유 또는 null(미정의)
+ */
+ public function reason(string $code): ?string
+ {
+ $key = "sirsoft-message_bizppurio::result_codes.{$code}";
+ $reason = __($key);
+
+ // __() 는 미정의 시 키 문자열을 그대로 반환 → 미정의로 판정.
+ return $reason === $key ? null : $reason;
+ }
+
+ /**
+ * 표시용 라벨 `사유 (코드)` 을 반환합니다.
+ *
+ * lang 에 사유가 있으면 "사유 (코드)", 없으면 코드만 반환한다.
+ *
+ * @param string $code 결과 코드
+ * @return string 표시 라벨
+ */
+ public function label(string $code): string
+ {
+ $reason = $this->reason($code);
+
+ return $reason === null ? $code : "{$reason} ({$code})";
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php
new file mode 100644
index 00000000..6981b34c
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsChannelDriver.php
@@ -0,0 +1,227 @@
+mobile, 비회원=알림 data 의 _recipient_phone
+ * (게스트 전화번호는 각 도메인 extract_data 리스너가 data 에 주입 — D1).
+ * 3. refkey(우리 부여 unique 키) 생성 → SmsTypeResolver 로 SMS/LMS 판별 →
+ * MessagePayloadBuilder 로 payload 조립 → SendMessageJob 위임(발송·재시도는 Job 책임).
+ *
+ * 1~2단계는 비즈뿌리오 API 호출 자체를 시도하지 못하는 사전 조건 미비 상태라
+ * NotificationSendSkippedException 을 던진다. 코어 NotificationDispatcher 의
+ * catch(\Exception)가 이를 channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌
+ * "실패"로 정확히 기록되게 한다(조용히 return 하면 코어가 "정상 처리 완료"로 오인해 성공으로
+ * 기록하는 문제 — 이슈 #28).
+ *
+ * 발송 이력(bizppurio_dispatches) 영속화는 Phase 4(테이블 신설)에서 이 흐름에 연결한다.
+ * Phase 3 은 refkey 생성 + payload 조립 + Job 위임까지 담당한다.
+ */
+class SmsChannelDriver
+{
+ /** 플러그인 식별자 (manifest 와 일치) */
+ private const PLUGIN_IDENTIFIER = 'sirsoft-message_bizppurio';
+
+ /** 알림 data 에서 비회원 전화번호를 싣는 표준 키 (extract_data 리스너와 계약) */
+ public const RECIPIENT_PHONE_KEY = '_recipient_phone';
+
+ /**
+ * @param NotificationTemplateService $templateService sms 채널 본문 템플릿 resolve
+ * @param NotificationDefinitionService $definitionService 알림 유형의 사람이 읽는 이름 조회(스킵 예외 메시지용)
+ * @param SmsTypeResolver $typeResolver SMS/LMS byte 판별
+ * @param MessagePayloadBuilder $payloadBuilder 발송 payload 조립
+ * @param BizppurioDispatchRepositoryInterface $dispatches 발송 이력 영속화(Phase 4)
+ * @param DispatchLinkContext $linkContext 발송 사이클 refkey↔코어 로그 연결 컨텍스트(A-2)
+ * @param PluginSettingsService $pluginSettings 검수 모드 여부 조회(이력 스냅샷용)
+ */
+ public function __construct(
+ private readonly NotificationTemplateService $templateService,
+ private readonly NotificationDefinitionService $definitionService,
+ private readonly SmsTypeResolver $typeResolver,
+ private readonly MessagePayloadBuilder $payloadBuilder,
+ private readonly BizppurioDispatchRepositoryInterface $dispatches,
+ private readonly DispatchLinkContext $linkContext,
+ private readonly PluginSettingsService $pluginSettings,
+ ) {}
+
+ /**
+ * 알림 유형의 사람이 읽는 이름을 반환합니다 (스킵 예외 메시지용).
+ *
+ * 정의 조회 실패·이름 미설정 시 코드값(type)을 그대로 반환한다(안전 폴백).
+ *
+ * @param string $type 알림 유형 코드값 (welcome 등)
+ * @return string 사람이 읽는 이름 또는 코드값
+ */
+ private function resolveTypeLabel(string $type): string
+ {
+ try {
+ $label = $this->definitionService->resolve($type)?->getLocalizedName();
+
+ return $label !== null && $label !== '' ? $label : $type;
+ } catch (\Throwable $e) {
+ return $type;
+ }
+ }
+
+ /**
+ * 알림을 문자(SMS/LMS)로 발송합니다.
+ *
+ * Laravel NotificationSender 가 'sms' 채널 드라이버로 이 메서드를 호출한다.
+ * GenericNotification 이 아닌 알림은 대상이 아니므로 조용히 무시한다.
+ *
+ * @param object $notifiable 수신자 (User 또는 GuestNotifiable)
+ * @param Notification $notification 발송 대상 알림
+ */
+ public function send(object $notifiable, Notification $notification): void
+ {
+ if (! $notification instanceof GenericNotification) {
+ return;
+ }
+
+ $type = $notification->getType();
+
+ // 1. sms 채널 본문 템플릿 resolve (없으면 발송 안 함 — Phase 6 에서 기본 body 시드)
+ // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를
+ // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다.
+ $template = $this->templateService->resolve($type, DispatchChannel::Sms->value);
+ if ($template === null || ! $template->is_active) {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.sms_template_missing', ['type' => $this->resolveTypeLabel($type)])
+ );
+ }
+
+ // 2. 전화번호 해석 (회원=mobile, 비회원=data 의 _recipient_phone)
+ $to = $this->resolvePhone($notifiable, $notification->getData());
+ if ($to === null) {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.recipient_phone_missing', ['type' => $this->resolveTypeLabel($type)])
+ );
+ }
+
+ // 3. 본문 렌더 (변수 치환)
+ $locale = BaseNotification::resolveNotifiableLocale($notifiable);
+ $rendered = $template->replaceVariables($notification->getData(), $locale);
+ $message = (string) ($rendered['body'] ?? '');
+ if (trim($message) === '') {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.message_body_empty', ['type' => $this->resolveTypeLabel($type)])
+ );
+ }
+
+ // 4. refkey 생성 → SMS/LMS 판별 → payload 조립
+ $refkey = $this->generateRefkey();
+ $channel = $this->typeResolver->resolve($message);
+
+ $payload = $channel === DispatchChannel::Lms
+ ? $this->payloadBuilder->buildLms($to, $message, $refkey, (string) ($rendered['subject'] ?? ''))
+ : $this->payloadBuilder->buildSms($to, $message, $refkey);
+
+ // 5. 발송 이력 pending 생성(Phase 4) → Job 위임. Job 이 refkey 로 조회해 sent/failed 갱신.
+ $this->dispatches->create([
+ 'refkey' => $refkey,
+ 'channel' => $channel->value,
+ 'to_number' => $to,
+ 'to_name' => $notifiable->name ?? null,
+ 'to_user_id' => $this->resolveUserId($notifiable),
+ 'content' => $message,
+ 'request_payload' => $this->payloadBuilder->forHistory($payload),
+ 'notification_type' => $type,
+ 'status' => DispatchStatus::Pending->value,
+ 'source' => DispatchSource::Auto->value,
+ 'is_test_mode' => $this->isTestMode(),
+ 'sent_at' => now(),
+ ]);
+
+ // A-2: 이 발송 사이클 직후 발화할 코어 알림 로그(after_log_sent)에 이 dispatch 를 잇도록
+ // refkey 를 컨텍스트에 남긴다. LinkNotificationLogListener 가 그 로그 id 를 여기에 연결한다.
+ $this->linkContext->remember($refkey);
+
+ SendMessageJob::dispatch($payload, $refkey);
+ }
+
+ /**
+ * 수신자가 회원이면 user id 를, 비회원(GuestNotifiable)이면 null 을 반환합니다.
+ *
+ * @param object $notifiable 수신자
+ * @return int|null 회원 ID 또는 null
+ */
+ private function resolveUserId(object $notifiable): ?int
+ {
+ // User 모델만 회원 PK 로 취급. GuestNotifiable 은 DB 회원이 아니므로 null.
+ if ($notifiable instanceof User) {
+ return (int) $notifiable->getKey();
+ }
+
+ return null;
+ }
+
+ /**
+ * 수신자의 전화번호를 해석합니다.
+ *
+ * 회원(Notifiable)은 mobile 속성을, 비회원은 알림 data 의 _recipient_phone 을 사용한다.
+ * 숫자·하이픈 외 문자는 제거하고, 값이 없으면 null 을 반환한다.
+ *
+ * @param object $notifiable 수신자
+ * @param array $data 알림 data
+ * @return string|null 정규화된 전화번호 또는 null
+ */
+ private function resolvePhone(object $notifiable, array $data): ?string
+ {
+ $raw = $notifiable->mobile
+ ?? ($data[self::RECIPIENT_PHONE_KEY] ?? null);
+
+ $normalized = preg_replace('/[^0-9]/', '', (string) $raw);
+
+ return ($normalized === null || $normalized === '') ? null : $normalized;
+ }
+
+ /**
+ * webhook 매칭용 refkey(UTF-8 최대 32byte, unique)를 생성합니다.
+ *
+ * 32자 hex(=32byte)로 고정해 발송 payload refkey 제약을 만족한다.
+ *
+ * @return string 32자 refkey
+ */
+ private function generateRefkey(): string
+ {
+ return Str::random(32);
+ }
+
+ /**
+ * 검수 모드 여부를 반환합니다 (발송 이력 스냅샷용).
+ *
+ * 기본값(미설정)은 안전하게 검수(true)로 간주한다(BizppurioApiClient::baseUrl() 과 동일 정책).
+ *
+ * @return bool 검수 모드면 true
+ */
+ private function isTestMode(): bool
+ {
+ return (bool) $this->pluginSettings->get(self::PLUGIN_IDENTIFIER, 'is_test_mode', true);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php
new file mode 100644
index 00000000..84e01bbd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/SmsTypeResolver.php
@@ -0,0 +1,90 @@
+byteLength($message) <= self::SMS_MAX_BYTES
+ ? DispatchChannel::Sms
+ : DispatchChannel::Lms;
+ }
+
+ /**
+ * 본문의 EUC-KR 기준 byte 길이를 반환합니다.
+ *
+ * 이모지 등 EUC-KR 미표현 문자를 제거한 뒤 변환하여 byte 를 측정한다.
+ *
+ * @param string $message 대상 문자열(UTF-8)
+ * @return int EUC-KR byte 길이
+ */
+ public function byteLength(string $message): int
+ {
+ $sanitized = $this->stripUnsupported($message);
+ $eucKr = mb_convert_encoding($sanitized, 'EUC-KR', 'UTF-8');
+
+ return strlen($eucKr);
+ }
+
+ /**
+ * LMS 최대 byte 를 초과하는지 여부를 반환합니다.
+ *
+ * @param string $message 변수 치환이 끝난 최종 본문
+ * @return bool LMS 한도(2000byte) 초과 시 true
+ */
+ public function exceedsLmsLimit(string $message): bool
+ {
+ return $this->byteLength($message) > self::LMS_MAX_BYTES;
+ }
+
+ /**
+ * EUC-KR 로 표현 불가능한 문자(이모지 등)를 제거합니다.
+ *
+ * mb_convert_encoding 은 미표현 문자를 '?' 로 바꿔 byte 를 왜곡시키므로,
+ * UTF-8 → EUC-KR → UTF-8 왕복 후 원본과 달라진(=치환된) 문자를 걸러낸다.
+ *
+ * @param string $message 대상 문자열(UTF-8)
+ * @return string EUC-KR 표현 가능한 문자만 남긴 문자열
+ */
+ private function stripUnsupported(string $message): string
+ {
+ $substituteBackup = mb_substitute_character();
+ // 미표현 문자를 삭제(none)하도록 설정해 왜곡 없이 제거
+ mb_substitute_character('none');
+
+ try {
+ $eucKr = mb_convert_encoding($message, 'EUC-KR', 'UTF-8');
+
+ return mb_convert_encoding($eucKr, 'UTF-8', 'EUC-KR');
+ } finally {
+ mb_substitute_character($substituteBackup);
+ }
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php
new file mode 100644
index 00000000..0a566668
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/WebhookReportService.php
@@ -0,0 +1,158 @@
+ $report 검증된 리포트 (REFKEY/RESULT/MEDIA/TELRES/KAORES + raw)
+ */
+ public function apply(array $report): void
+ {
+ $refkey = (string) ($report['REFKEY'] ?? '');
+ $resultCode = (string) ($report['RESULT'] ?? '');
+
+ // ① REFKEY 매칭 — 없으면 위조/미매칭, 멱등 흡수
+ $dispatch = $this->dispatches->findByRefkey($refkey);
+ if ($dispatch === null) {
+ return;
+ }
+
+ // ② replay — 이미 리포트 반영된 이력이면 멱등
+ if ($this->wasAlreadyReported($dispatch)) {
+ $this->logReplayDetected($refkey, $resultCode);
+
+ return;
+ }
+
+ // ③ 결과 분류 → 상태 전이 + 리포트 필드 기록
+ $category = $this->resolver->categorize($resultCode);
+ $status = $category === ResultCategory::Success
+ ? DispatchStatus::Success
+ : DispatchStatus::Failed;
+
+ $this->dispatches->update($dispatch, [
+ 'status' => $status->value,
+ 'media' => $report['MEDIA'] ?? null,
+ 'result_code' => $resultCode,
+ 'result_message' => $this->resolver->reason($resultCode),
+ 'fallback_status' => $this->resolveFallbackStatus($report, $dispatch),
+ 'reported_at' => now(),
+ 'raw_payload' => $report['raw'] ?? $report,
+ ]);
+
+ // ④ 잔액부족(9070/7436) → 관리자 자체 알림 (쿨다운 내 1회만, D3)
+ if ($category === ResultCategory::BalanceLow) {
+ $this->notifyBalanceLowOnce($resultCode, $dispatch->channel->value);
+ }
+ }
+
+ /**
+ * 잔액부족 알림을 쿨다운 내 1회만 발송합니다 (D3 중복 방지).
+ *
+ * 잔액이 부족하면 발송건마다 9070/7436 이 쏟아지므로, 그때마다 알림을 보내면
+ * 관리자에게 알림이 폭주한다. 채널별로 캐시 키를 두어, 쿨다운(기본 1시간) 동안
+ * 이미 알렸으면 발송을 건너뛴다. 발송 이력의 실패 기록(③)은 이와 무관하게 매 건
+ * 남으므로, 실패 내역 자체는 모두 보존된다.
+ *
+ * 쿨다운은 environment 설정(balance_low_notify_cooldown, 초)으로 조정 가능하며,
+ * 화면 노출 없이 설정 파일에서만 관리한다. 잔액을 충전하면 9070/7436 이 더 이상
+ * 발생하지 않으므로 알림도 자연히 멈춘다.
+ *
+ * @param string $resultCode 결과 코드(9070 문자 / 7436 알림톡)
+ * @param string $channel 채널(sms / lms / alimtalk)
+ */
+ private function notifyBalanceLowOnce(string $resultCode, string $channel): void
+ {
+ $cacheKey = "bizppurio:balance_low_notified:{$channel}";
+
+ if ($this->cache->has($cacheKey)) {
+ return;
+ }
+
+ $cooldown = (int) $this->pluginSettings->get(
+ self::PLUGIN_IDENTIFIER,
+ 'balance_low_notify_cooldown',
+ self::DEFAULT_BALANCE_LOW_COOLDOWN,
+ );
+
+ $this->cache->put($cacheKey, true, $cooldown);
+
+ HookManager::doAction(
+ 'sirsoft-message_bizppurio.balance.low',
+ $resultCode,
+ $channel,
+ );
+ }
+
+ /**
+ * 대체발송 결과를 해석합니다 (webhook TELRES/KAORES).
+ *
+ * 비즈뿌리오는 대체발송 미요청 건에도 TELRES/KAORES 에 "0" 같은 값을 채워 보내므로
+ * (실측), webhook 응답값만으로는 대체발송 발생 여부를 판단할 수 없다. 우리가 발송
+ * 시점에 실제로 대체발송을 요청했는지(`request_payload` 의 resend/recontent 존재)를
+ * 먼저 확인해, 요청하지 않았으면 webhook 값과 무관하게 null 을 반환한다.
+ *
+ * @param array $report 리포트
+ * @param BizppurioDispatch $dispatch webhook 매칭된 발송 이력 (발송 시점 요청 payload 보유)
+ * @return string|null 대체발송 결과 코드 또는 null
+ */
+ private function resolveFallbackStatus(array $report, BizppurioDispatch $dispatch): ?string
+ {
+ $requestPayload = $dispatch->request_payload ?? [];
+ if (! isset($requestPayload['resend'])) {
+ return null;
+ }
+
+ $telres = $report['TELRES'] ?? null;
+ $kaores = $report['KAORES'] ?? null;
+
+ return $telres !== null && $telres !== ''
+ ? (string) $telres
+ : ($kaores !== null && $kaores !== '' ? (string) $kaores : null);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php
new file mode 100644
index 00000000..262d94b7
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/routes/api.php
@@ -0,0 +1,150 @@
+withoutMiddleware([EnforceIdentityPolicy::class, RefreshTokenExpiration::class])
+ ->name('webhook');
+
+Route::prefix('admin')->name('admin.')->middleware(['auth:sanctum', 'admin'])->group(function () {
+ // 리포트 수신 주소 조회 (관리자 설정 페이지 표시용)
+ //
+ // 비즈뿌리오가 발송 결과(리포트)를 URL PUSH 로 전송할 수신 주소를 사이트 도메인
+ // 기준 절대 URL 로 내려준다. url() 헬퍼는 리버스 프록시 뒤에서 요청 host 가
+ // localhost 로 떨어질 수 있어, 운영자가 관리하는 config('app.url') 을 신뢰 소스로
+ // 삼아 절대화한다. 운영자가 접속한 주소와 무관하게 항상 정식 도메인이 표시된다.
+ //
+ // ※ 실제 리포트 수신 처리(POST /webhook) 는 Phase 4 에서 구현한다.
+ Route::get('/report-url', function () {
+ $origin = rtrim((string) config('app.url', 'http://localhost'), '/');
+
+ return response()->json([
+ 'success' => true,
+ 'data' => [
+ 'url' => $origin.'/api/plugins/sirsoft-message_bizppurio/webhook',
+ ],
+ ]);
+ })->middleware('permission:admin,core.plugins.read')
+ ->name('report.url');
+
+ // 알림톡 템플릿 화면 준비 상태(값 유무) 조회.
+ //
+ // 카카오 관리에 필요한 자격증명(api_key·sender_key)은 sensitive:true 라 코어 설정 조회
+ // 응답에서 제거된다(보안). 따라서 프론트가 설정 조회 값으로 "설정됨" 을 판정할 수 없어,
+ // 값 자체는 노출하지 않고 저장 여부(boolean)만 내려준다. 알림톡 탭 readiness 배너가 소비.
+ Route::get('/templates-readiness', function () {
+ $settings = app(PluginSettingsService::class)
+ ->get('sirsoft-message_bizppurio') ?? [];
+
+ $filled = static fn (string $key): bool => trim((string) ($settings[$key] ?? '')) !== '';
+
+ return response()->json([
+ 'success' => true,
+ 'data' => [
+ 'api_key_set' => $filled('api_key'),
+ 'sender_key_set' => $filled('sender_key'),
+ 'ready' => $filled('api_key') && $filled('sender_key'),
+ ],
+ ]);
+ })->middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')
+ ->name('templates.readiness');
+
+ /*
+ |----------------------------------------------------------------------
+ | 알림톡 템플릿 조회 (Phase 5) — 카카오 관리 API(kapi) 실시간 위임
+ |----------------------------------------------------------------------
+ |
+ | 조회 전용(list/detail/categories/profiles) = messaging.view.
+ | 템플릿 등록·수정·삭제·검수·상태변경은 비즈뿌리오 콘솔로 위임한다(이 화면은 목록·상태·
+ | 내용 조회 + 알림 연결만 담당). 템플릿은 DB 저장 없이 매 요청 실시간 조회하며, 설정
+ | 페이지 알림톡 템플릿 탭이 소비한다.
+ */
+ Route::prefix('alimtalk-templates')->name('alimtalk-templates.')->group(function () {
+ // 발송 템플릿 내용 캐시 초기화(수동 갱신) — 카카오에서 템플릿을 방금 바꿔 즉시 반영이
+ // 필요할 때 관리자가 캐시를 비운다. 쓰기 동작이므로 messaging.manage 권한. 구체 경로를
+ // 먼저 두어 GET /{templateCode} 와 충돌하지 않게 한다.
+ Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () {
+ Route::post('/cache/clear', [AlimtalkTemplateController::class, 'clearCache'])->name('cache.clear');
+ });
+
+ Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () {
+ Route::get('/', [AlimtalkTemplateController::class, 'index'])->name('index');
+ Route::get('/categories', [AlimtalkTemplateController::class, 'categories'])->name('categories');
+ Route::get('/profiles', [AlimtalkTemplateController::class, 'profiles'])->name('profiles');
+ Route::get('/{templateCode}', [AlimtalkTemplateController::class, 'show'])->name('show');
+ });
+ });
+
+ /*
+ |----------------------------------------------------------------------
+ | 알림↔알림톡 템플릿 연동 (Phase 6 재설계) — 코어 편집 모달 전용 칸이 소비
+ |----------------------------------------------------------------------
+ |
+ | 알림톡 탭은 코어 기본 목록·편집 모달을 그대로 쓴다(⚑⚑ 결정 A). 연결 템플릿·SMS 대체
+ | 입력은 코어 편집 모달에 얹은 전용 칸(플러그인 overlay)에서 하고, 값을 바꾸면 즉시 이 API
+ | 로 저장한다(PO 확정 UX — 별도 저장 버튼 없이 변경 즉시 저장, 코어 저장 버튼 무관 →
+ | 코어 템플릿 무오염).
+ |
+ | 조회(index/approved-templates) = messaging.view / 저장(store) = messaging.manage.
+ */
+ Route::prefix('notification-bindings')->name('notification-bindings.')->group(function () {
+ Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () {
+ Route::get('/', [NotificationBindingController::class, 'index'])->name('index');
+ Route::get('/approved-templates', [NotificationBindingController::class, 'approvedTemplates'])->name('approved-templates');
+ });
+
+ Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')->group(function () {
+ Route::post('/', [NotificationBindingController::class, 'store'])->name('store');
+ });
+ });
+
+ /*
+ |----------------------------------------------------------------------
+ | 코어 알림 발송 이력 결과 조회 (A-2) — 코어 이력 화면 overlay 가 소비
+ |----------------------------------------------------------------------
+ |
+ | 코어 "알림 발송 이력" 화면에 plugin overlay 로 얹은 결과 컬럼이, 현재 페이지의 코어 알림
+ | 로그 id 배열을 이 API 로 넘겨 비즈뿌리오 결과(상태·사유·잔액부족·대체발송)를 한 번에 조회
+ | 한다(N+1 회피). 코어 화면·코어 테이블 무수정 — 연결 표식은 우리 dispatch 쪽에만 둔다.
+ |
+ | 조회 = messaging.view.
+ */
+ Route::prefix('dispatch-results')->name('dispatch-results.')->group(function () {
+ Route::middleware('permission:admin,sirsoft-message_bizppurio.messaging.view')->group(function () {
+ // 화면 결과 컬럼용: 파라미터 없이 최근 결과 맵을 받아 row.id 로 매칭(타이밍 무관, 기본 경로).
+ Route::get('/recent', [DispatchResultController::class, 'recent'])->name('recent');
+ // 명시 로그 id 배열 조회(직접 조회용). 화면은 recent 를 쓴다.
+ Route::post('/lookup', [DispatchResultController::class, 'lookup'])->name('lookup');
+ });
+ });
+
+ // 인증 토큰 재검증 — 저장된 계정/비밀번호가 유효한지 그 자리에서 확인한다(설정
+ // 화면 "연결 확인" 버튼). 캐시를 거치지 않고 매번 /v1/token 을 새로 호출하므로
+ // 조회가 아닌 쓰기 성격에 준해 messaging.manage 권한을 요구한다.
+ Route::post('/token/check', [TokenCheckController::class, 'check'])
+ ->middleware('permission:admin,sirsoft-message_bizppurio.messaging.manage')
+ ->name('token.check');
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php
new file mode 100644
index 00000000..7548335e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php
@@ -0,0 +1,261 @@
+app->instance(AlimtalkTemplateService::class, $mock);
+
+ return $mock;
+ }
+
+ /**
+ * 지정 권한을 가진 admin 사용자를 생성한다.
+ *
+ * @param array $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 $permissions
+ * @return array
+ */
+ 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=none_of_required,action=index
+ *
+ * @effects list_requires_view_permission_returns_403
+ */
+ public function test_view권한_없으면_목록조회_403(): void
+ {
+ $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.other']))
+ ->getJson(self::BASE);
+
+ $response->assertStatus(403);
+ }
+
+ /**
+ * @scenario auth=view,service=ok
+ *
+ * @effects show_returns_template_detail_with_status_badge
+ */
+ public function test_view권한으로_상세를_조회한다(): void
+ {
+ $this->mockService()->shouldReceive('detail')->once()->with('TW_1')->andReturn([
+ 'templateCode' => 'TW_1',
+ 'templateName' => '주문완료',
+ 'status_badge' => ['variant' => 'green'],
+ ]);
+
+ $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
+ ->getJson(self::BASE.'/TW_1');
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('data.template.templateCode', 'TW_1');
+ $response->assertJsonPath('data.template.status_badge.variant', 'green');
+ }
+
+ /**
+ * @scenario auth=view,action=store
+ *
+ * @effects store_route_removed_returns_405_read_only
+ */
+ public function test_등록_라우트는_제거되어_조회전용이다(): void
+ {
+ // 조회 전용 전환으로 등록(POST)·상태변경 라우트를 제거했다. manage 미들웨어 블록이
+ // 사라졌으므로 POST 는 라우트 미매칭(405)이 되어야 한다(등록은 비즈뿌리오 콘솔).
+ $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
+ ->postJson(self::BASE, [
+ 'templateName' => 'T',
+ 'templateContent' => '본문',
+ 'categoryCode' => '001',
+ 'templateEmphasizeType' => 'NONE',
+ ]);
+
+ $response->assertStatus(405);
+ }
+
+ /**
+ * @scenario auth=view,service=throws
+ *
+ * @effects kapi_failure_is_surfaced_as_422_with_result_code
+ */
+ public function test_kapi_실패는_422로_전파된다(): void
+ {
+ $this->mockService()->shouldReceive('list')->once()
+ ->andThrow(new BizppurioApiException('발신프로필을 찾을 수 없습니다.', resultCode: '7204'));
+
+ $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
+ ->getJson(self::BASE);
+
+ $response->assertStatus(422);
+ $response->assertJsonPath('errors.result_code', '7204');
+ $response->assertJsonPath('errors.kakao_message', '발신프로필을 찾을 수 없습니다.');
+ }
+
+ /**
+ * @scenario auth=view,kapi_result=not_found
+ *
+ * @effects list_treats_kapi_508_as_empty_result_not_error
+ */
+ public function test_kapi_508은_에러가_아니라_빈_목록으로_응답한다(): void
+ {
+ $this->mockService()->shouldReceive('list')->once()->andReturn([
+ 'templates' => [],
+ 'pagination' => ['total' => 0, 'total_page' => 1, 'current_page' => 1, 'per_page' => 20],
+ ]);
+
+ $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
+ ->getJson(self::BASE.'?keyword=대글');
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('data.templates', []);
+ $response->assertJsonPath('data.pagination.total', 0);
+ }
+
+ /**
+ * @scenario auth=manage,action=clear_cache
+ *
+ * @effects clear_cache_delegates_to_binding_service_and_returns_count
+ */
+ public function test_manage권한으로_발송내용_캐시를_초기화한다(): void
+ {
+ $bindings = Mockery::mock(NotificationBindingService::class);
+ $bindings->shouldReceive('clearTemplateContentCache')->once()->andReturn(3);
+ $this->app->instance(NotificationBindingService::class, $bindings);
+
+ $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.manage']))
+ ->postJson(self::BASE.'/cache/clear');
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('data.cleared', 3);
+ }
+
+ /**
+ * @scenario auth=view_only,action=clear_cache
+ *
+ * @effects clear_cache_requires_manage_permission_returns_403
+ */
+ public function test_캐시초기화는_manage권한이_없으면_403(): void
+ {
+ // 조회(view) 권한만으로는 캐시 초기화(쓰기)를 할 수 없다.
+ $response = $this->withHeaders($this->authHeaders(['sirsoft-message_bizppurio.messaging.view']))
+ ->postJson(self::BASE.'/cache/clear');
+
+ $response->assertStatus(403);
+ }
+
+ /**
+ * @scenario auth=guest,action=clear_cache
+ *
+ * @effects clear_cache_requires_authentication_returns_401
+ */
+ public function test_캐시초기화는_비인증이면_401(): void
+ {
+ $this->postJson(self::BASE.'/cache/clear')->assertStatus(401);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php
new file mode 100644
index 00000000..f19bfff6
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateCleanupOnUninstallTest.php
@@ -0,0 +1,144 @@
+plugin = new Plugin;
+ }
+
+ /**
+ * activate() 로 채널이 증강된 상태에서 uninstall() 을 호출하면, 코어 알림 정의는
+ * 보존한 채 sms·alimtalk template 과 channels 배열의 우리 채널만 제거된다.
+ */
+ public function test_uninstall_시_sms_alimtalk_템플릿이_정리되고_코어_정의는_보존된다(): void
+ {
+ $this->plugin->activate();
+
+ $this->assertGreaterThan(
+ 0,
+ NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(),
+ '사전조건: activate() 후 sms·alimtalk 템플릿이 존재해야 한다.'
+ );
+
+ $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail();
+ $this->assertContains('sms', $welcome->channels);
+ $this->assertContains('alimtalk', $welcome->channels);
+
+ $this->plugin->uninstall();
+
+ $this->assertSame(
+ 0,
+ NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(),
+ 'uninstall() 후에는 sms·alimtalk 채널 템플릿이 하나도 남으면 안 된다.'
+ );
+
+ $welcome->refresh();
+ $this->assertEqualsCanonicalizing(
+ ['mail', 'database'],
+ $welcome->channels,
+ 'welcome 정의는 삭제되지 않고, channels 배열에서 sms·alimtalk 만 제거되어야 한다.'
+ );
+ $this->assertNotNull(
+ NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'mail')->first(),
+ 'mail 채널 템플릿은 그대로 보존되어야 한다.'
+ );
+ $this->assertNotNull(
+ NotificationTemplate::where('definition_id', $welcome->id)->where('channel', 'database')->first(),
+ 'database 채널 템플릿은 그대로 보존되어야 한다.'
+ );
+ }
+
+ /**
+ * 게시판·이커머스처럼 코어가 아닌 다른 모듈이 소유한 알림 정의도 동일하게,
+ * 정의 자체는 남고 우리 채널만 제거된다.
+ */
+ public function test_uninstall_시_모듈_소유_알림도_채널만_정리되고_정의는_보존된다(): void
+ {
+ $this->plugin->activate();
+
+ $boardDefinition = NotificationDefinition::where('extension_identifier', 'sirsoft-board')->first();
+
+ if (! $boardDefinition) {
+ $this->markTestSkipped('sirsoft-board 모듈이 활성화되지 않은 테스트 환경입니다.');
+ }
+
+ $this->assertContains('sms', $boardDefinition->channels);
+
+ $this->plugin->uninstall();
+
+ $boardDefinition->refresh();
+ $this->assertNotContains('sms', $boardDefinition->channels);
+ $this->assertNotContains('alimtalk', $boardDefinition->channels);
+ $this->assertSame(
+ 0,
+ NotificationTemplate::where('definition_id', $boardDefinition->id)
+ ->whereIn('channel', ['sms', 'alimtalk'])
+ ->count()
+ );
+ }
+
+ /**
+ * 제거 후 재설치(재활성화)하면 activate() 의 재시딩 경로로 채널이 다시 생성된다(회귀 방지).
+ */
+ public function test_제거_후_재활성화하면_채널이_다시_생성된다(): void
+ {
+ $this->plugin->activate();
+ $this->plugin->uninstall();
+
+ $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count());
+
+ $this->plugin->activate();
+
+ $this->assertGreaterThan(
+ 0,
+ NotificationTemplate::where('channel', 'sms')->count(),
+ '재활성화 후 sms 채널 템플릿이 다시 생성되어야 한다.'
+ );
+ $this->assertGreaterThan(
+ 0,
+ NotificationTemplate::where('channel', 'alimtalk')->count(),
+ '재활성화 후 alimtalk 채널 템플릿이 다시 생성되어야 한다.'
+ );
+
+ $welcome = NotificationDefinition::where('type', 'welcome')->firstOrFail();
+ $this->assertContains('sms', $welcome->channels);
+ $this->assertContains('alimtalk', $welcome->channels);
+ }
+
+ /**
+ * 채널 증강이 전혀 없는 상태(activate 를 호출하지 않은 상태)에서 uninstall() 을
+ * 호출해도 예외 없이 안전하게 통과해야 한다(정리 대상 0건).
+ */
+ public function test_증강된_채널이_없는_상태에서_uninstall해도_안전하다(): void
+ {
+ app(NotificationDefinitionSeeder::class)->run();
+
+ $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count());
+
+ $result = $this->plugin->uninstall();
+
+ $this->assertTrue($result);
+ $this->assertSame(0, NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count());
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php
new file mode 100644
index 00000000..608dafcf
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/ChannelTemplateSeedOnActivateTest.php
@@ -0,0 +1,55 @@
+run();
+
+ $this->assertSame(
+ 0,
+ NotificationTemplate::whereIn('channel', ['sms', 'alimtalk'])->count(),
+ '활성화 전에는 sms·alimtalk 채널 템플릿이 없어야 한다(설치 전 상태).'
+ );
+
+ // [실행] 정식 활성화 진입점. 이 안에서 리스너 선등록 → 재시딩이 채널을 증강해야 한다.
+ (new Plugin)->activate();
+
+ // [검증] 회원 대상 코어 알림(welcome 등)에 sms·alimtalk 템플릿이 생성됐다.
+ $this->assertGreaterThan(
+ 0,
+ NotificationTemplate::where('channel', 'sms')->count(),
+ 'activate() 후 sms 채널 템플릿이 회원 알림에 생성돼야 한다.'
+ );
+ $this->assertGreaterThan(
+ 0,
+ NotificationTemplate::where('channel', 'alimtalk')->count(),
+ 'activate() 후 alimtalk 채널 템플릿이 회원 알림에 생성돼야 한다.'
+ );
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php
new file mode 100644
index 00000000..ef9e92c9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php
@@ -0,0 +1,178 @@
+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('is_test_mode', $schema);
+ $this->assertSame('boolean', $schema['is_test_mode']['type']);
+ $this->assertTrue($schema['is_test_mode']['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']);
+ }
+ }
+
+ /**
+ * 관리자 메뉴를 추가하지 않는다 (화면 배치 결정 2026-07-14, ⚑ 결정 3).
+ *
+ * 플러그인 메뉴 추가는 정식 기능이 아니므로 getAdminMenus()·메뉴 sync 를
+ * 폐기했다. 진입은 코어 소유 설정 페이지(/admin/plugins/{id}/settings) 하나로
+ * 통일한다. 활성화해도 플러그인 소속 메뉴 row 가 생성되지 않아야 한다.
+ */
+ public function test_activate_시_관리자_메뉴를_생성하지_않는다(): void
+ {
+ $this->plugin->activate();
+
+ $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(),
+ '최상위 메시지 발송 관리 메뉴가 남아 있으면 안 된다.'
+ );
+ }
+
+ /**
+ * Phase 4 발송 이력·연동 테이블이 마이그레이션으로 생성된다.
+ */
+ public function test_phase4_테이블이_생성된다(): void
+ {
+ $this->assertTrue(Schema::hasTable('bizppurio_dispatches'));
+ $this->assertTrue(Schema::hasTable('bizppurio_notification_bindings'));
+
+ $this->assertTrue(Schema::hasColumns('bizppurio_dispatches', [
+ 'refkey', 'messagekey', 'channel', 'to_number', 'to_user_id',
+ 'status', 'result_code', 'reported_at', 'raw_payload',
+ ]));
+ }
+
+ /**
+ * 마이그레이션 up→down→up 왕복 후에도 리포지토리 호출 1회전이 동작한다.
+ */
+ public function test_마이그레이션_왕복_후_리포지토리가_동작한다(): void
+ {
+ $migrations = base_path('plugins/_bundled/sirsoft-message_bizppurio/database/migrations');
+
+ // down
+ $this->artisan('migrate:rollback', ['--path' => $migrations, '--realpath' => true])->run();
+ $this->assertFalse(Schema::hasTable('bizppurio_dispatches'));
+
+ // up
+ $this->artisan('migrate', ['--path' => $migrations, '--realpath' => true])->run();
+ $this->assertTrue(Schema::hasTable('bizppurio_dispatches'));
+
+ // 왕복 후 Repository 호출 1회전 (create → 조회)
+ $dispatch = BizppurioDispatch::create([
+ 'refkey' => 'roundtrip',
+ 'channel' => 'sms',
+ 'to_number' => '01011112222',
+ 'content' => 'x',
+ 'status' => 'sent',
+ 'source' => 'auto',
+ ]);
+ $this->assertNotNull(BizppurioDispatch::query()->byRefkey('roundtrip')->first());
+ $this->assertSame('roundtrip', $dispatch->refkey);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php
new file mode 100644
index 00000000..c104b193
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php
@@ -0,0 +1,164 @@
+ $settings 플러그인 설정 stub
+ */
+ private function registerListener(array $settings = []): void
+ {
+ $stub = new class($settings) extends PluginSettingsService
+ {
+ /** @param array $map */
+ public function __construct(private array $map) {}
+
+ public function get(string $identifier, ?string $key = null, mixed $default = null): mixed
+ {
+ return $this->map[$key] ?? $default;
+ }
+ };
+
+ $listener = new RegisterNotificationChannelsListener($stub);
+
+ HookManager::addFilter(
+ 'core.notification.filter_available_channels',
+ fn ($channels) => $listener->addChannels($channels),
+ 20,
+ );
+ HookManager::addFilter(
+ 'core.notification.channel_readiness',
+ fn ($result, $channelId) => $listener->checkReadiness($result, $channelId),
+ 20,
+ );
+ HookManager::addFilter(
+ 'core.notification.channel_enabled',
+ fn ($enabled, $extType, $extId, $channelId) => $listener->gateChannelEnabled($enabled, $extType, $extId, $channelId),
+ 20,
+ );
+ }
+
+ /**
+ * 코어 SettingsService 의 notifications.channels 저장값을 지정합니다.
+ *
+ * @param array> $channels
+ */
+ private function setCoreChannels(array $channels): void
+ {
+ $settings = \Mockery::mock(SettingsService::class);
+ $settings->shouldReceive('getSetting')
+ ->with('notifications.channels', [])
+ ->andReturn($channels);
+ $this->app->instance(SettingsService::class, $settings);
+ app(NotificationChannelService::class)->clearChannelEnabledCache();
+ }
+
+ public function test_코어_채널서비스가_sms_alimtalk을_노출한다(): void
+ {
+ $this->registerListener();
+
+ // 실제 코어 서비스가 filter_available_channels 훅을 발화한다
+ $channels = app(NotificationChannelService::class)->getAvailableChannels();
+ $ids = array_column($channels, 'id');
+
+ $this->assertContains('sms', $ids);
+ $this->assertContains('alimtalk', $ids);
+ }
+
+ public function test_코어_channelservice가_비회원_허용을_인식한다(): void
+ {
+ $this->registerListener();
+
+ $service = app(NotificationChannelService::class);
+
+ $this->assertTrue($service->isChannelGuestAllowed('sms'), 'sms 는 allow_guest:true 여야 한다.');
+ $this->assertTrue($service->isChannelGuestAllowed('alimtalk'));
+ }
+
+ public function test_코어_readiness서비스가_미설정_sms를_not_ready로_판정한다(): void
+ {
+ $this->registerListener([]); // 설정 비어 있음
+
+ $result = app(ChannelReadinessService::class)->check('sms');
+
+ $this->assertFalse($result['ready']);
+ $this->assertStringContainsString('readiness.sms_credentials_missing', (string) $result['reason']);
+ }
+
+ public function test_코어_readiness서비스가_완비_sms를_ready로_판정한다(): void
+ {
+ $this->registerListener([
+ 'bizppurio_id' => 'acme',
+ 'password' => 'secret',
+ 'sender_number' => '025550000',
+ ]);
+
+ $result = app(ChannelReadinessService::class)->check('sms');
+
+ $this->assertTrue($result['ready']);
+ }
+
+ public function test_channel_enabled_미저장_sms는_off로_덮인다(): void
+ {
+ $this->registerListener();
+ // mail/database 만 저장, sms/alimtalk 엔트리 없음(미저장)
+ $this->setCoreChannels([
+ ['id' => 'mail', 'is_active' => true],
+ ['id' => 'database', 'is_active' => true],
+ ]);
+
+ $service = app(NotificationChannelService::class);
+
+ // 우리 채널: 미저장 → OFF (opt-in)
+ $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'sms'));
+ $service->clearChannelEnabledCache();
+ $this->assertFalse($service->isChannelEnabledForExtension('core', 'core', 'alimtalk'));
+ // 코어 기본 채널: 미저장이어도 ON 유지(하위호환)
+ $service->clearChannelEnabledCache();
+ $this->assertTrue($service->isChannelEnabledForExtension('core', 'core', 'mail'));
+ }
+
+ public function test_channel_enabled_저장된_on_sms는_on이다(): void
+ {
+ $this->registerListener();
+ $this->setCoreChannels([
+ ['id' => 'mail', 'is_active' => true],
+ ['id' => 'sms', 'is_active' => true],
+ ]);
+
+ $this->assertTrue(
+ app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms')
+ );
+ }
+
+ public function test_channel_enabled_저장된_off_sms는_off이다(): void
+ {
+ $this->registerListener();
+ $this->setCoreChannels([
+ ['id' => 'mail', 'is_active' => true],
+ ['id' => 'sms', 'is_active' => false],
+ ]);
+
+ $this->assertFalse(
+ app(NotificationChannelService::class)->isChannelEnabledForExtension('core', 'core', 'sms')
+ );
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php
new file mode 100644
index 00000000..9cf9527b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php
@@ -0,0 +1,228 @@
+ $permissionIds 부여할 권한 식별자
+ */
+ private function adminWith(array $permissionIds): User
+ {
+ $user = User::factory()->create();
+
+ $adminRole = Role::firstOrCreate(
+ ['identifier' => 'admin'],
+ ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
+ );
+
+ $permIds = [];
+ foreach ($permissionIds as $identifier) {
+ $permission = Permission::firstOrCreate(
+ ['identifier' => $identifier],
+ ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
+ );
+ $permIds[] = $permission->id;
+ }
+
+ $testRole = Role::create([
+ 'identifier' => 'bizppurio_result_test_'.uniqid(),
+ 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
+ 'type' => 'admin',
+ ]);
+ $testRole->permissions()->sync($permIds);
+
+ $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+ $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+
+ return $user->fresh();
+ }
+
+ /**
+ * 사용자 토큰 헤더를 만듭니다.
+ */
+ private function authHeaders(User $user): array
+ {
+ return [
+ 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken,
+ 'Accept' => 'application/json',
+ ];
+ }
+
+ /**
+ * 코어 알림 로그 1건 + 연결된 dispatch 1건을 만들고 로그 id 를 반환합니다.
+ *
+ * @param array $dispatchAttrs dispatch 오버라이드
+ */
+ private function seedLinkedLog(array $dispatchAttrs = []): int
+ {
+ $log = NotificationLog::create([
+ 'channel' => 'sms',
+ 'notification_type' => 'welcome',
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'core',
+ 'recipient_identifier' => '01011112222',
+ 'recipient_name' => '홍길동',
+ 'status' => 'sent',
+ 'source' => 'notification',
+ 'sent_at' => now(),
+ ]);
+
+ BizppurioDispatch::create(array_merge([
+ 'refkey' => 'rk_'.uniqid(),
+ 'channel' => 'sms',
+ 'to_number' => '01011112222',
+ 'content' => '본문',
+ 'notification_type' => 'welcome',
+ 'notification_log_id' => $log->id,
+ 'status' => 'success',
+ 'result_code' => '4100',
+ 'source' => 'auto',
+ 'sent_at' => now(),
+ ], $dispatchAttrs));
+
+ return (int) $log->id;
+ }
+
+ private const RECENT_URL = '/api/plugins/sirsoft-message_bizppurio/admin/dispatch-results/recent';
+
+ public function test_인증_없이_조회는_401이다(): void
+ {
+ $this->postJson(self::URL, ['notification_log_ids' => [1]])->assertStatus(401);
+ }
+
+ public function test_recent_는_view_권한_없으면_403이다(): void
+ {
+ $user = $this->adminWith([]);
+
+ $this->getJson(self::RECENT_URL, $this->authHeaders($user))->assertStatus(403);
+ }
+
+ public function test_recent_는_파라미터_없이_최근_결과맵을_반환한다(): void
+ {
+ $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+ $logId = $this->seedLinkedLog(['result_code' => '4100']);
+
+ $result = $this->getJson(self::RECENT_URL, $this->authHeaders($user))
+ ->assertOk()
+ ->json("data.results.{$logId}");
+
+ $this->assertSame('success', $result['status']);
+ $this->assertSame('4100', $result['result_code']);
+ }
+
+ public function test_view_권한_없으면_403이다(): void
+ {
+ $user = $this->adminWith([]); // 권한 없음
+ $logId = $this->seedLinkedLog();
+
+ $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
+ ->assertStatus(403);
+ }
+
+ public function test_로그_id_키가_아예_없으면_422다(): void
+ {
+ $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+
+ $this->postJson(self::URL, [], $this->authHeaders($user))
+ ->assertStatus(422)
+ ->assertJsonValidationErrors(['notification_log_ids']);
+ }
+
+ public function test_빈_배열은_200과_빈_결과맵을_반환한다(): void
+ {
+ // 코어 알림 발송 이력이 0건(또는 아직 미로드)일 때 화면은 빈 배열을 보낸다.
+ // 이 경우 422 가 아니라 200 + 빈 결과 맵이어야 화면이 깨지지 않는다(회귀: 브라우저 실측).
+ $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+
+ $response = $this->postJson(self::URL, ['notification_log_ids' => []], $this->authHeaders($user))
+ ->assertOk();
+
+ $this->assertSame([], $response->json('data.results'));
+ }
+
+ public function test_성공_결과를_로그id_키_맵으로_반환한다(): void
+ {
+ $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+ $logId = $this->seedLinkedLog();
+
+ $response = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
+ ->assertOk();
+
+ $result = $response->json("data.results.{$logId}");
+ $this->assertSame('success', $result['status']);
+ $this->assertSame('4100', $result['result_code']);
+ $this->assertFalse($result['is_low_balance']);
+ }
+
+ public function test_실패_음영지역은_사유_코드_라벨을_반환한다(): void
+ {
+ $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+ $logId = $this->seedLinkedLog(['status' => 'failed', 'result_code' => '4400']);
+
+ $result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
+ ->assertOk()
+ ->json("data.results.{$logId}");
+
+ // result_label 은 `사유 (코드)` 형식 — result_codes lang 에 4400 정의가 있으면 사유가 붙는다.
+ $this->assertSame('failed', $result['status']);
+ $this->assertStringContainsString('4400', (string) $result['result_label']);
+ }
+
+ public function test_잔액부족_코드는_is_low_balance_true다(): void
+ {
+ $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+ $logId = $this->seedLinkedLog(['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']);
+
+ $result = $this->postJson(self::URL, ['notification_log_ids' => [$logId]], $this->authHeaders($user))
+ ->assertOk()
+ ->json("data.results.{$logId}");
+
+ $this->assertTrue($result['is_low_balance']);
+ }
+
+ public function test_매칭되지_않는_로그id는_결과맵에서_빠진다(): void
+ {
+ $user = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+ $linkedId = $this->seedLinkedLog();
+
+ // 연결되지 않은 코어 로그(메일 등) — dispatch 없음
+ $mailLog = NotificationLog::create([
+ 'channel' => 'mail',
+ 'notification_type' => 'welcome',
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'core',
+ 'recipient_identifier' => 'a@b.com',
+ 'status' => 'sent',
+ 'source' => 'notification',
+ 'sent_at' => now(),
+ ]);
+
+ $results = $this->postJson(
+ self::URL,
+ ['notification_log_ids' => [$linkedId, $mailLog->id]],
+ $this->authHeaders($user)
+ )->assertOk()->json('data.results');
+
+ $this->assertArrayHasKey((string) $linkedId, $results);
+ $this->assertArrayNotHasKey((string) $mailLog->id, $results);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php
new file mode 100644
index 00000000..106c2050
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php
@@ -0,0 +1,251 @@
+app->instance(AlimtalkTemplateService::class, $mock);
+
+ return $mock;
+ }
+
+ /**
+ * 지정 template_code 목록을 승인(ACT) 상태로 반환하는 mock 을 등록한다.
+ *
+ * @param array $codes 승인 처리할 template_code 목록
+ */
+ private function stubApprovedTemplates(array $codes): void
+ {
+ $templates = array_map(
+ fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'],
+ $codes,
+ );
+
+ $this->mockTemplateService()
+ ->shouldReceive('list')
+ ->andReturn(['templates' => $templates, 'pagination' => []]);
+ }
+
+ /**
+ * 지정 권한 식별자들을 가진 admin 사용자를 만듭니다.
+ *
+ * @param array $permissionIds 부여할 권한 식별자
+ */
+ private function adminWith(array $permissionIds): User
+ {
+ $user = User::factory()->create();
+
+ $adminRole = Role::firstOrCreate(
+ ['identifier' => 'admin'],
+ ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
+ );
+
+ $permIds = [];
+ foreach ($permissionIds as $identifier) {
+ $permission = Permission::firstOrCreate(
+ ['identifier' => $identifier],
+ ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
+ );
+ $permIds[] = $permission->id;
+ }
+
+ $testRole = Role::create([
+ 'identifier' => 'bizppurio_binding_test_'.uniqid(),
+ 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
+ 'type' => 'admin',
+ ]);
+ $testRole->permissions()->sync($permIds);
+
+ $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+ $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+
+ return $user->fresh();
+ }
+
+ /**
+ * 사용자 토큰으로 요청 헤더를 만듭니다.
+ */
+ private function authHeaders(User $user): array
+ {
+ return [
+ 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken,
+ 'Accept' => 'application/json',
+ ];
+ }
+
+ public function test_인증_없이_목록_조회는_401이다(): void
+ {
+ $this->getJson(self::BASE)->assertStatus(401);
+ }
+
+ public function test_view_권한으로_연동_맵을_조회한다(): void
+ {
+ BizppurioNotificationBinding::create([
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ 'template_code' => 'TW_1236',
+ 'template_name' => '가입환영',
+ 'fallback_sms_enabled' => true,
+ 'is_active' => true,
+ ]);
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE);
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('data.bindings.welcome.template_code', 'TW_1236');
+ $response->assertJsonPath('data.bindings.welcome.fallback_sms_enabled', true);
+ }
+
+ public function test_연동이_없으면_빈_맵을_반환한다(): void
+ {
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->getJson(self::BASE);
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('data.bindings', []);
+ }
+
+ public function test_권한_없는_사용자는_승인템플릿_조회_403이다(): void
+ {
+ $admin = $this->adminWith([]); // messaging.view 없음
+
+ $response = $this->withHeaders($this->authHeaders($admin))
+ ->getJson(self::BASE.'/approved-templates');
+
+ $response->assertStatus(403);
+ }
+
+ public function test_view_권한만으로는_저장할_수_없다(): void
+ {
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
+ 'notification_type' => 'welcome',
+ 'template_code' => 'TW_1236',
+ 'template_name' => '가입환영',
+ ]);
+
+ $response->assertStatus(403);
+ }
+
+ public function test_manage_권한으로_연동을_즉시_저장한다(): void
+ {
+ $this->stubApprovedTemplates(['TW_1236']);
+ $admin = $this->adminWith([
+ 'sirsoft-message_bizppurio.messaging.view',
+ 'sirsoft-message_bizppurio.messaging.manage',
+ ]);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
+ 'notification_type' => 'welcome',
+ 'template_code' => 'TW_1236',
+ 'template_name' => '가입환영',
+ 'fallback_sms_enabled' => true,
+ ]);
+
+ $response->assertStatus(200);
+ $this->assertDatabaseHas('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ 'template_code' => 'TW_1236',
+ 'fallback_sms_enabled' => true,
+ ]);
+ }
+
+ public function test_미승인_템플릿_코드로_저장하면_422이고_저장되지_않는다(): void
+ {
+ // 회귀: 화면 드롭다운은 승인 템플릿만 보여주지만, 그 필터를 우회해 API 를 직접
+ // 호출하면 미승인 template_code 도 저장되던 결함. bind() 서버측 재검증으로 차단.
+ $this->stubApprovedTemplates(['TW_OTHER']);
+ $admin = $this->adminWith([
+ 'sirsoft-message_bizppurio.messaging.view',
+ 'sirsoft-message_bizppurio.messaging.manage',
+ ]);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
+ 'notification_type' => 'welcome',
+ 'template_code' => 'TW_UNAPPROVED',
+ 'template_name' => '미승인',
+ ]);
+
+ $response->assertStatus(422);
+ $this->assertDatabaseMissing('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'template_code' => 'TW_UNAPPROVED',
+ ]);
+ }
+
+ public function test_빈_코드로_저장하면_연동을_해제한다(): void
+ {
+ BizppurioNotificationBinding::create([
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ 'template_code' => 'TW_1236',
+ 'template_name' => '가입환영',
+ 'fallback_sms_enabled' => false,
+ 'is_active' => true,
+ ]);
+
+ $admin = $this->adminWith([
+ 'sirsoft-message_bizppurio.messaging.view',
+ 'sirsoft-message_bizppurio.messaging.manage',
+ ]);
+
+ // 해제는 카카오 승인 조회를 거치지 않으므로 mock 불필요.
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
+ 'notification_type' => 'welcome',
+ 'template_code' => '',
+ ]);
+
+ $response->assertStatus(200);
+ $this->assertDatabaseMissing('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ ]);
+ }
+
+ public function test_notification_type_누락_시_422다(): void
+ {
+ $admin = $this->adminWith([
+ 'sirsoft-message_bizppurio.messaging.view',
+ 'sirsoft-message_bizppurio.messaging.manage',
+ ]);
+
+ // FormRequest 검증(notification_type required)이 서비스 호출보다 먼저 실패하므로 mock 불필요.
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::BASE, [
+ 'template_code' => 'TW_1236',
+ ]);
+
+ $response->assertStatus(422);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php
new file mode 100644
index 00000000..626e5752
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php
@@ -0,0 +1,159 @@
+ $config) {
+ HookManager::addAction(
+ $hook,
+ fn (...$args) => app(LinkNotificationLogListener::class)->{$config['method']}(...$args),
+ $config['priority'] ?? 10,
+ );
+ }
+ }
+
+ /**
+ * 발송 사이클을 흉내내 pending dispatch 를 만들고 refkey 를 컨텍스트에 남깁니다.
+ *
+ * 채널 드라이버 send() 의 dispatch 생성 + linkContext->remember() 와 동일한 상태를 만든다.
+ *
+ * @param string $channel 발송 채널
+ * @return string 부여한 refkey
+ */
+ private function beginDispatchCycle(string $channel = 'sms'): string
+ {
+ $refkey = 'rk_'.uniqid();
+
+ BizppurioDispatch::create([
+ 'refkey' => $refkey,
+ 'channel' => $channel,
+ 'to_number' => '01011112222',
+ 'content' => '본문',
+ 'notification_type' => 'welcome',
+ 'status' => 'pending',
+ 'source' => 'auto',
+ 'sent_at' => now(),
+ ]);
+
+ // 발송 사이클과 동일한 싱글턴 인스턴스에 refkey 를 남긴다.
+ app(DispatchLinkContext::class)->remember($refkey);
+
+ return $refkey;
+ }
+
+ public function test_발송_성공_로그가_dispatch에_연결된다(): void
+ {
+ $refkey = $this->beginDispatchCycle();
+
+ // 코어 로그 서비스로 실제 로그 생성 → after_log_sent 훅 발화 → 우리 리스너가 연결
+ $log = app(NotificationLogService::class)->logSent([
+ 'channel' => 'sms',
+ 'notification_type' => 'welcome',
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'core',
+ 'recipient_identifier' => '01011112222',
+ 'recipient_name' => '홍길동',
+ 'source' => 'notification',
+ 'sent_at' => now(),
+ ]);
+
+ $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first();
+ $this->assertNotNull($dispatch);
+ $this->assertSame((int) $log->id, (int) $dispatch->notification_log_id);
+ }
+
+ public function test_발송_실패_로그도_dispatch에_연결된다(): void
+ {
+ $refkey = $this->beginDispatchCycle('alimtalk');
+
+ $log = app(NotificationLogService::class)->logFailed([
+ 'channel' => 'alimtalk',
+ 'notification_type' => 'welcome',
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'core',
+ 'recipient_identifier' => '01011112222',
+ 'error_message' => 'boom',
+ 'source' => 'notification',
+ 'sent_at' => now(),
+ ]);
+
+ $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first();
+ $this->assertSame((int) $log->id, (int) $dispatch->notification_log_id);
+ }
+
+ public function test_비_비즈뿌리오_로그는_연결되지_않는다(): void
+ {
+ // remember() 를 부르지 않은 상태(mail/database 등 비-비즈뿌리오 발송 사이클)
+ $log = app(NotificationLogService::class)->logSent([
+ 'channel' => 'mail',
+ 'notification_type' => 'welcome',
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'core',
+ 'recipient_identifier' => 'a@b.com',
+ 'source' => 'notification',
+ 'sent_at' => now(),
+ ]);
+
+ // 어떤 dispatch 도 이 로그에 연결되지 않아야 한다.
+ $linked = BizppurioDispatch::query()->where('notification_log_id', $log->id)->exists();
+ $this->assertFalse($linked);
+ }
+
+ public function test_한_refkey는_한_로그에만_연결된다_consume_후_비워짐(): void
+ {
+ $refkey = $this->beginDispatchCycle();
+
+ // 첫 로그 → 연결됨
+ $first = app(NotificationLogService::class)->logSent([
+ 'channel' => 'sms',
+ 'notification_type' => 'welcome',
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'core',
+ 'recipient_identifier' => '01011112222',
+ 'source' => 'notification',
+ 'sent_at' => now(),
+ ]);
+
+ // 같은 사이클에서 refkey 를 다시 남기지 않은 채 둘째 로그 발생(다른 채널) → 연결 안 됨
+ $second = app(NotificationLogService::class)->logSent([
+ 'channel' => 'database',
+ 'notification_type' => 'welcome',
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'core',
+ 'recipient_identifier' => '01011112222',
+ 'source' => 'notification',
+ 'sent_at' => now(),
+ ]);
+
+ $dispatch = BizppurioDispatch::query()->where('refkey', $refkey)->first();
+ $this->assertSame((int) $first->id, (int) $dispatch->notification_log_id);
+ $this->assertFalse(
+ BizppurioDispatch::query()->where('notification_log_id', $second->id)->exists()
+ );
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php
new file mode 100644
index 00000000..dc8a9408
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php
@@ -0,0 +1,228 @@
+getSettingsSchema() 를 사용)
+ $manager = app(PluginManager::class);
+ $ref = new \ReflectionClass($manager);
+ $prop = $ref->getProperty('plugins');
+ $prop->setAccessible(true);
+ $plugins = $prop->getValue($manager);
+ $plugins[self::IDENTIFIER] = new Plugin;
+ $prop->setValue($manager, $plugins);
+
+ // 검증 규칙 필터 훅 등록 (실제 훅 체인 재현 — mock 없음).
+ $listener = new ValidateBizppurioSettingsListener;
+ HookManager::addFilter(
+ 'core.plugin_settings.update_rules',
+ [$listener, 'addLiveModeRules'],
+ 10
+ );
+
+ $this->admin = $this->createAdminUser();
+ $this->token = $this->admin->createToken('test-token')->plainTextToken;
+ }
+
+ /**
+ * core.plugins.update 권한을 가진 admin 사용자 생성.
+ */
+ private function createAdminUser(): User
+ {
+ $user = User::factory()->create();
+
+ $permission = Permission::firstOrCreate(
+ ['identifier' => 'core.plugins.update'],
+ ['name' => json_encode(['ko' => '플러그인 수정', 'en' => 'Update Plugins']), 'type' => 'admin']
+ );
+
+ $adminRole = Role::firstOrCreate(
+ ['identifier' => 'admin'],
+ ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
+ );
+
+ $testRole = Role::create([
+ 'identifier' => 'bizppurio_settings_test_'.uniqid(),
+ 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
+ 'type' => 'admin',
+ ]);
+ $testRole->permissions()->sync([$permission->id]);
+
+ $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();
+ }
+
+ /**
+ * 인증 헤더가 적용된 PUT 요청 헬퍼.
+ *
+ * @param array $body
+ * @return TestResponse
+ */
+ private function putSettings(array $body)
+ {
+ return $this->withHeaders([
+ 'Authorization' => 'Bearer '.$this->token,
+ 'Accept' => 'application/json',
+ ])->putJson('/api/admin/plugins/'.self::IDENTIFIER.'/settings', $body);
+ }
+
+ /**
+ * @scenario test_mode=off,credentials=empty
+ *
+ * @effects live_mode_with_empty_credentials_returns_422_with_bizppurio_id_password_sender_number_errors
+ */
+ public function test_live_mode_with_empty_credentials_is_rejected(): void
+ {
+ $response = $this->putSettings([
+ 'is_test_mode' => false,
+ 'bizppurio_id' => '',
+ 'password' => '',
+ 'sender_number' => '',
+ ]);
+
+ $response->assertStatus(422);
+ $response->assertJsonValidationErrors(['bizppurio_id', 'password', 'sender_number']);
+ }
+
+ /**
+ * @scenario test_mode=off,credentials=empty
+ *
+ * @effects api_key_and_sender_key_are_not_required_in_live_mode
+ */
+ public function test_live_mode_does_not_require_api_key_or_sender_key(): void
+ {
+ $response = $this->putSettings([
+ 'is_test_mode' => false,
+ 'bizppurio_id' => 'biz-account',
+ 'password' => 'secret',
+ 'sender_number' => '021234567',
+ 'api_key' => '',
+ 'sender_key' => '',
+ ]);
+
+ // api_key / sender_key 는 문자 발송 필수 조건이 아니므로 빈 값이어도 통과해야 한다.
+ $response->assertStatus(200);
+ }
+
+ /**
+ * @scenario test_mode=off,credentials=empty,locale=ko
+ *
+ * @effects validation_error_messages_use_korean_field_labels_not_english_keys
+ */
+ public function test_validation_error_messages_use_korean_field_labels(): void
+ {
+ $response = $this->putSettings([
+ 'is_test_mode' => false,
+ 'bizppurio_id' => '',
+ 'password' => '',
+ 'sender_number' => '',
+ ]);
+
+ $response->assertStatus(422);
+
+ $errors = $response->json('errors');
+ // 코어 검증기 수정 없이 리스너의 Lang::addLines 로 주입한 한국어 라벨이 노출되어야 한다.
+ $this->assertStringContainsString('비즈뿌리오 아이디', $errors['bizppurio_id'][0]);
+ $this->assertStringNotContainsString('bizppurio id', $errors['bizppurio_id'][0]);
+ $this->assertStringContainsString('발신번호', $errors['sender_number'][0]);
+ }
+
+ /**
+ * @scenario test_mode=off,credentials=filled,locale=ko
+ *
+ * @effects live_mode_with_filled_credentials_returns_200
+ */
+ public function test_live_mode_with_filled_credentials_is_accepted(): void
+ {
+ $response = $this->putSettings([
+ 'is_test_mode' => false,
+ 'bizppurio_id' => 'biz-account',
+ 'password' => 'secret',
+ 'sender_number' => '021234567',
+ ]);
+
+ $response->assertStatus(200);
+ }
+
+ /**
+ * @scenario test_mode=on,credentials=empty,locale=ko
+ *
+ * @effects dev_mode_with_empty_credentials_returns_200
+ */
+ public function test_dev_mode_with_empty_credentials_is_accepted(): void
+ {
+ $response = $this->putSettings([
+ 'is_test_mode' => true,
+ 'bizppurio_id' => '',
+ 'password' => '',
+ 'sender_number' => '',
+ ]);
+
+ $response->assertStatus(200);
+ }
+
+ /**
+ * @scenario test_mode=off,credentials=filled
+ *
+ * @effects password_is_not_stored_as_plaintext_in_settings_file, password_decrypts_back_to_original_via_settings_service_get
+ */
+ public function test_password_is_stored_encrypted(): void
+ {
+ $this->putSettings([
+ 'is_test_mode' => false,
+ 'bizppurio_id' => 'biz-account',
+ 'password' => 'super-secret',
+ 'sender_number' => '021234567',
+ ])->assertStatus(200);
+
+ // 저장 파일에 평문이 남지 않아야 한다 (sensitive 암호화).
+ // PluginTestCase 가 'plugins' 디스크를 테스트 전용 경로로 격리(#458)하므로 실제
+ // storage_path 가 아니라 격리된 디스크를 통해 파일을 조회한다.
+ $relativePath = self::IDENTIFIER.'/settings/setting.json';
+ $this->assertTrue(
+ \Illuminate\Support\Facades\Storage::disk('plugins')->exists($relativePath),
+ '설정 파일이 격리된 plugins 디스크에 저장되어야 한다.'
+ );
+ $raw = \Illuminate\Support\Facades\Storage::disk('plugins')->get($relativePath);
+ $this->assertStringNotContainsString('super-secret', $raw);
+
+ // 복호화 왕복은 원문과 일치해야 한다.
+ $value = app(PluginSettingsService::class)->get(self::IDENTIFIER, 'password');
+ $this->assertSame('super-secret', $value);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php
new file mode 100644
index 00000000..289ba568
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php
@@ -0,0 +1,84 @@
+create();
+
+ $permission = Permission::firstOrCreate(
+ ['identifier' => 'core.plugins.read'],
+ ['name' => json_encode(['ko' => '플러그인 조회', 'en' => 'Read Plugins']), 'type' => 'admin']
+ );
+
+ $adminRole = Role::firstOrCreate(
+ ['identifier' => 'admin'],
+ ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
+ );
+
+ $testRole = Role::create([
+ 'identifier' => 'bizppurio_report_test_'.uniqid(),
+ 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
+ 'type' => 'admin',
+ ]);
+ $testRole->permissions()->sync([$permission->id]);
+
+ $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();
+ }
+
+ /**
+ * @scenario auth=admin,app_url=set
+ *
+ * @effects report_url_returns_absolute_url_built_from_app_url
+ */
+ public function test_returns_absolute_report_url_based_on_app_url(): void
+ {
+ config(['app.url' => 'https://shop.example.com']);
+
+ $admin = $this->createAdminUser();
+ $token = $admin->createToken('test-token')->plainTextToken;
+
+ $response = $this->withHeaders([
+ 'Authorization' => 'Bearer '.$token,
+ 'Accept' => 'application/json',
+ ])->getJson(self::ENDPOINT);
+
+ $response->assertStatus(200);
+ $response->assertJsonPath('data.url', 'https://shop.example.com/api/plugins/sirsoft-message_bizppurio/webhook');
+ }
+
+ /**
+ * @scenario auth=guest
+ *
+ * @effects report_url_requires_authentication_returns_401
+ */
+ public function test_requires_authentication(): void
+ {
+ $response = $this->getJson(self::ENDPOINT);
+
+ $response->assertStatus(401);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php
new file mode 100644
index 00000000..6afa6f17
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php
@@ -0,0 +1,180 @@
+ $permissionIds 부여할 권한 식별자
+ */
+ private function adminWith(array $permissionIds): User
+ {
+ $user = User::factory()->create();
+
+ $adminRole = Role::firstOrCreate(
+ ['identifier' => 'admin'],
+ ['name' => json_encode(['ko' => '관리자', 'en' => 'Admin']), 'type' => 'admin']
+ );
+
+ $permIds = [];
+ foreach ($permissionIds as $identifier) {
+ $permission = Permission::firstOrCreate(
+ ['identifier' => $identifier],
+ ['name' => json_encode(['ko' => $identifier, 'en' => $identifier]), 'type' => 'admin']
+ );
+ $permIds[] = $permission->id;
+ }
+
+ $testRole = Role::create([
+ 'identifier' => 'bizppurio_token_check_test_'.uniqid(),
+ 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']),
+ 'type' => 'admin',
+ ]);
+ $testRole->permissions()->sync($permIds);
+
+ $user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+ $user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
+
+ return $user->fresh();
+ }
+
+ /**
+ * 사용자 토큰으로 요청 헤더를 만듭니다.
+ *
+ * @return array
+ */
+ private function authHeaders(User $user): array
+ {
+ return [
+ 'Authorization' => 'Bearer '.$user->createToken('test')->plainTextToken,
+ 'Accept' => 'application/json',
+ ];
+ }
+
+ /**
+ * 비즈뿌리오 아이디/비밀번호를 저장합니다.
+ */
+ private function storeCredentials(string $id = 'biz-account', string $password = 'secret'): void
+ {
+ app(PluginSettingsService::class)->save(self::IDENTIFIER, [
+ 'bizppurio_id' => $id,
+ 'password' => $password,
+ 'is_test_mode' => true,
+ ]);
+ }
+
+ public function test_인증_없이_요청하면_401이다(): void
+ {
+ $this->postJson(self::ENDPOINT)->assertStatus(401);
+ }
+
+ public function test_manage_권한_없이_요청하면_403이다(): void
+ {
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.view']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
+
+ $response->assertStatus(403);
+ }
+
+ public function test_자격증명이_유효하면_200을_반환한다(): void
+ {
+ Http::fake([
+ '*/v1/token' => Http::response(['accesstoken' => 'VALID_TOKEN', 'type' => 'Bearer'], 200),
+ ]);
+
+ $this->storeCredentials();
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
+
+ $response->assertStatus(200);
+ $response->assertJson(['success' => true]);
+ }
+
+ public function test_자격증명이_유효하면_캐시가_새_토큰으로_갱신된다(): void
+ {
+ Http::fake([
+ '*/v1/token' => Http::response(['accesstoken' => 'FRESH_TOKEN', 'type' => 'Bearer'], 200),
+ ]);
+
+ $this->storeCredentials();
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
+
+ // BizppurioTokenService 가 contextual binding 으로 받는 것과 동일한 캐시
+ // (식별자 네임스페이스 + 기본 캐시 스토어) — 전역 app(CacheInterface::class) 는
+ // 코어 네임스페이스라 별개 저장 공간이므로 사용할 수 없다.
+ $cache = new PluginCacheDriver(self::IDENTIFIER, config('cache.default'));
+ $cache->flush();
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
+
+ $response->assertStatus(200);
+ $this->assertSame('FRESH_TOKEN', $cache->get('bizppurio:token'));
+ }
+
+ public function test_비즈뿌리오_서버에_연결할_수_없으면_500_대신_422를_반환한다(): void
+ {
+ Http::fake(['*/v1/token' => fn () => throw new ConnectionException('Connection refused')]);
+
+ $this->storeCredentials();
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
+
+ $response->assertStatus(422);
+ }
+
+ public function test_비즈뿌리오가_실패_사유를_반환하면_422와_함께_원문_사유가_노출된다(): void
+ {
+ Http::fake([
+ '*/v1/token' => Http::response(['code' => '3007', 'description' => 'invalid password in bizppurio'], 401),
+ ]);
+
+ $this->storeCredentials();
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
+
+ $response->assertStatus(422);
+ $response->assertJsonPath('errors.result_code', '3007');
+ // 원문 사유는 메시지 키 자리가 아니라 관리자 전용 errors 페이로드로 노출된다
+ // (예외→응답 매핑 규정 — GenericCatchStatusCodeContractTest 가 키 자리 원문을 차단).
+ $this->assertStringContainsString('invalid password in bizppurio', $response->json('errors.bizppurio_message'));
+ $this->assertStringNotContainsString('invalid password in bizppurio', (string) $response->json('message'));
+ }
+
+ public function test_자격증명_미설정시_422를_반환한다(): void
+ {
+ $this->storeCredentials(id: '', password: '');
+ $admin = $this->adminWith(['sirsoft-message_bizppurio.messaging.manage']);
+
+ $response = $this->withHeaders($this->authHeaders($admin))->postJson(self::ENDPOINT);
+
+ $response->assertStatus(422);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php
new file mode 100644
index 00000000..9d50d5d4
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php
@@ -0,0 +1,319 @@
+|null $requestPayload 발송 시점 요청 payload (resend 포함 여부 검증용)
+ * @return BizppurioDispatch
+ */
+ private function seedDispatch(string $refkey, string $channel = 'sms', ?array $requestPayload = null): BizppurioDispatch
+ {
+ return BizppurioDispatch::create([
+ 'refkey' => $refkey,
+ 'channel' => $channel,
+ 'to_number' => '01011112222',
+ 'content' => '테스트 본문',
+ 'notification_type' => 'welcome',
+ 'status' => DispatchStatus::Sent->value,
+ 'source' => 'auto',
+ 'request_payload' => $requestPayload,
+ 'sent_at' => now(),
+ ]);
+ }
+
+ /**
+ * 허용 IP 로 유효 리포트를 POST 하면 200 + 상태가 success 로 갱신.
+ *
+ * @scenario source=webhook,ip=allowed,result=success
+ *
+ * @effects dispatch_status_updated_to_success,reported_at_set
+ */
+ public function test_allowed_ip_success_report_updates_status(): void
+ {
+ $dispatch = $this->seedDispatch('ref_success');
+
+ $response = $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, [
+ 'REFKEY' => 'ref_success',
+ 'RESULT' => '4100',
+ 'MEDIA' => 'SMS',
+ ]);
+
+ $response->assertStatus(200);
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Success, $dispatch->status);
+ $this->assertSame('4100', $dispatch->result_code);
+ $this->assertSame('SMS', $dispatch->media);
+ $this->assertNotNull($dispatch->reported_at);
+ }
+
+ /**
+ * 실패 결과코드는 status=failed 로 갱신.
+ *
+ * @scenario source=webhook,ip=allowed,result=failure
+ *
+ * @effects dispatch_status_updated_to_failed
+ */
+ public function test_allowed_ip_failure_report_marks_failed(): void
+ {
+ $dispatch = $this->seedDispatch('ref_fail');
+
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_fail', 'RESULT' => '4400'])
+ ->assertStatus(200);
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Failed, $dispatch->status);
+ $this->assertSame('4400', $dispatch->result_code);
+ }
+
+ /**
+ * 차단 IP 는 403 + 이력 미변경.
+ *
+ * @scenario source=webhook,ip=blocked
+ *
+ * @effects webhook_blocked_ip_returns_403,dispatch_unchanged
+ */
+ public function test_blocked_ip_returns_403(): void
+ {
+ $dispatch = $this->seedDispatch('ref_blocked');
+
+ $this->withServerVariables(['REMOTE_ADDR' => self::BLOCKED_IP])
+ ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_blocked', 'RESULT' => '4100'])
+ ->assertStatus(403);
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Sent, $dispatch->status);
+ $this->assertNull($dispatch->reported_at);
+ }
+
+ /**
+ * 미매칭 refkey(위조)는 200 흡수 + 아무 이력도 변경 안 됨.
+ *
+ * @scenario source=webhook,ip=allowed,refkey=unknown
+ *
+ * @effects webhook_unknown_refkey_absorbed_200
+ */
+ public function test_unknown_refkey_absorbed_with_200(): void
+ {
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, ['REFKEY' => 'nope', 'RESULT' => '4100'])
+ ->assertStatus(200);
+
+ $this->assertDatabaseCount('bizppurio_dispatches', 0);
+ }
+
+ /**
+ * replay(이미 reported_at 있는 이력)는 멱등 — 상태를 다시 뒤집지 않음.
+ *
+ * @scenario source=webhook,ip=allowed,replay=true
+ *
+ * @effects webhook_replay_is_idempotent
+ */
+ public function test_replay_report_is_idempotent(): void
+ {
+ $dispatch = $this->seedDispatch('ref_replay');
+ $dispatch->update([
+ 'status' => DispatchStatus::Success->value,
+ 'result_code' => '4100',
+ 'reported_at' => now()->subMinute(),
+ ]);
+ $firstReportedAt = $dispatch->fresh()->reported_at;
+
+ // 두 번째(중복) 리포트가 실패 코드로 와도 성공 상태를 유지해야 함
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_replay', 'RESULT' => '4400'])
+ ->assertStatus(200);
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Success, $dispatch->status);
+ $this->assertSame('4100', $dispatch->result_code);
+ $this->assertEquals($firstReportedAt->timestamp, $dispatch->reported_at->timestamp);
+ }
+
+ /**
+ * 잔액부족(9070 문자)은 failed + 잔액부족 자체알림 훅 1회 발화.
+ *
+ * @scenario source=webhook,ip=allowed,result=balance_low_sms
+ *
+ * @effects webhook_balance_low_marks_failed,balance_low_hook_fired
+ */
+ public function test_balance_low_sms_marks_failed_and_fires_hook(): void
+ {
+ $dispatch = $this->seedDispatch('ref_balance', DispatchChannel::Sms->value);
+
+ $fired = [];
+ HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) {
+ $fired[] = $args;
+ });
+
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_balance', 'RESULT' => '9070'])
+ ->assertStatus(200);
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Failed, $dispatch->status);
+ $this->assertCount(1, $fired, '잔액부족 훅은 1회만 발화해야 함');
+ $this->assertSame(['9070', 'sms'], $fired[0]);
+ }
+
+ /**
+ * 잔액부족 알림은 쿨다운 동안 채널별 1회만 발송된다 (D3 중복 방지).
+ *
+ * 서로 다른 발송건이 연속으로 9070(문자 잔액부족)을 받아도, 쿨다운 내에서는
+ * 관리자 알림이 1회만 발화한다. 단, 발송 이력의 실패 기록은 두 건 모두 남는다.
+ *
+ * @scenario source=webhook,ip=allowed,result=balance_low_sms,repeat=true
+ *
+ * @effects balance_low_notification_deduplicated_within_cooldown,both_dispatches_marked_failed
+ */
+ public function test_balance_low_notification_is_deduplicated_within_cooldown(): void
+ {
+ $first = $this->seedDispatch('ref_bal_1', DispatchChannel::Sms->value);
+ $second = $this->seedDispatch('ref_bal_2', DispatchChannel::Sms->value);
+
+ $fired = [];
+ HookManager::addAction('sirsoft-message_bizppurio.balance.low', function (...$args) use (&$fired) {
+ $fired[] = $args;
+ });
+
+ // 두 발송건이 각각 9070(문자 잔액부족) 리포트를 받음
+ foreach (['ref_bal_1', 'ref_bal_2'] as $refkey) {
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, ['REFKEY' => $refkey, 'RESULT' => '9070'])
+ ->assertStatus(200);
+ }
+
+ // 알림은 1회만 (쿨다운 중복 방지)
+ $this->assertCount(1, $fired, '쿨다운 동안 잔액부족 알림은 1회만 발화해야 함');
+
+ // 그러나 실패 이력은 두 건 모두 기록됨
+ $this->assertSame(DispatchStatus::Failed, $first->fresh()->status);
+ $this->assertSame(DispatchStatus::Failed, $second->fresh()->status);
+ $this->assertSame('9070', $first->fresh()->result_code);
+ $this->assertSame('9070', $second->fresh()->result_code);
+ }
+
+ /**
+ * webhook 은 코어 토큰/IDV 미들웨어 제외 — 비인증 실 POST 가 401 이 아닌 200.
+ *
+ * @scenario source=webhook,ip=allowed,auth=none
+ *
+ * @effects webhook_bypasses_auth_middleware_returns_200
+ */
+ public function test_webhook_bypasses_auth_middleware(): void
+ {
+ $this->seedDispatch('ref_noauth');
+
+ // Authorization 헤더 없이(비인증) POST — 미들웨어 제외로 200 이어야 함
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, ['REFKEY' => 'ref_noauth', 'RESULT' => '4100'])
+ ->assertStatus(200);
+ }
+
+ /**
+ * 대체발송(SMS 대체발송)을 요청하지 않은 발송건(request_payload 에 resend 없음)은,
+ * webhook 응답에 TELRES/KAORES 값이 채워져 와도 fallback_status 가 null 로 남아야 한다.
+ *
+ * 비즈뿌리오는 대체발송 미요청 건에도 TELRES="0" 같은 값을 채워 보내므로(실서버 관측:
+ * alimtalk 대체발송 OFF 인데 결과 화면에 SMS 대체발송 뱃지가 표시된 회귀), webhook 값
+ * 존재 여부만으로 판단하면 안 되고 우리가 실제로 요청했는지(request_payload)를 봐야 한다.
+ *
+ * @scenario source=webhook,ip=allowed,result=success,fallback_requested=false
+ *
+ * @effects fallback_status_remains_null_when_not_requested
+ */
+ public function test_fallback_status_stays_null_when_resend_was_not_requested(): void
+ {
+ $dispatch = $this->seedDispatch(
+ 'ref_no_resend_requested',
+ DispatchChannel::Alimtalk->value,
+ ['account' => 'sirsoft', 'content' => ['at' => ['templatecode' => 'tpl_1']]],
+ );
+
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, [
+ 'REFKEY' => 'ref_no_resend_requested',
+ 'RESULT' => '7000',
+ 'MEDIA' => 'KAT',
+ 'TELRES' => '0',
+ 'KAORES' => '0',
+ ])
+ ->assertStatus(200);
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Success, $dispatch->status);
+ $this->assertNull($dispatch->fallback_status);
+ }
+
+ /**
+ * 대체발송을 실제로 요청한 발송건(request_payload 에 resend 있음)은 webhook TELRES 값을
+ * fallback_status 에 정상 반영한다.
+ *
+ * @scenario source=webhook,ip=allowed,result=balance_low,fallback_requested=true
+ *
+ * @effects fallback_status_set_when_resend_was_requested
+ */
+ public function test_fallback_status_set_when_resend_was_requested(): void
+ {
+ $dispatch = $this->seedDispatch(
+ 'ref_resend_requested',
+ DispatchChannel::Alimtalk->value,
+ [
+ 'account' => 'sirsoft',
+ 'content' => ['at' => ['templatecode' => 'tpl_1']],
+ 'resend' => ['first' => 'sms'],
+ ],
+ );
+
+ $this->withServerVariables(['REMOTE_ADDR' => self::ALLOWED_IP])
+ ->postJson(self::ENDPOINT, [
+ 'REFKEY' => 'ref_resend_requested',
+ 'RESULT' => '7436',
+ 'MEDIA' => 'KAT',
+ 'TELRES' => '4100',
+ ])
+ ->assertStatus(200);
+
+ $dispatch->refresh();
+ $this->assertSame('4100', $dispatch->fallback_status);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php
new file mode 100644
index 00000000..e957d824
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/PluginTestCase.php
@@ -0,0 +1,227 @@
+|null
+ */
+ private ?array $hookListenerRegistrarSnapshot = null;
+
+ /** 테스트 격리용 임시 plugins 스토리지 루트 (setUp 에서 생성, tearDown 에서 제거). */
+ private ?string $isolatedPluginsRoot = null;
+
+ /**
+ * 테스트 환경 설정
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->isolatePluginStorage();
+ $this->snapshotHookManager();
+ $this->snapshotHookListenerRegistrar();
+ $this->activateSelfForMiddlewareGate();
+ }
+
+ /**
+ * 자기 플러그인을 미들웨어 게이트 인덱스에 활성 등록한다.
+ *
+ * getMiddleware() self-gate 선언(webhook IP 화이트리스트)은 코어 게이트가
+ * 활성 플러그인 registry 를 대조해 실행하므로, 테스트 환경에서도 plugins 테이블
+ * 활성 행 + PluginManager 인스턴스 등록 + 인덱스 무효화가 있어야 매칭된다.
+ * 누락 시 게이트가 미들웨어를 부착하지 못해 차단 IP 도 통과한다.
+ */
+ protected function activateSelfForMiddlewareGate(): void
+ {
+ Plugin::query()->updateOrCreate(
+ ['identifier' => 'sirsoft-message_bizppurio'],
+ [
+ 'vendor' => 'sirsoft',
+ 'name' => json_encode(['ko' => 'sirsoft-message_bizppurio', 'en' => 'sirsoft-message_bizppurio']),
+ 'version' => '1.0.0',
+ 'status' => ExtensionStatus::Active->value,
+ ]
+ );
+ PluginManager::invalidatePluginStatusCache();
+
+ $pluginManager = $this->app->make(PluginManager::class);
+ $property = new \ReflectionProperty($pluginManager, 'plugins');
+ $property->setAccessible(true);
+ $plugins = $property->getValue($pluginManager);
+ $plugins['sirsoft-message_bizppurio'] = new \Plugins\Sirsoft\MessageBizppurio\Plugin;
+ $property->setValue($pluginManager, $plugins);
+
+ ExtensionMiddlewareRegistry::flush();
+ }
+
+ /**
+ * tearDown 에 HookManager 상태 + plugins 스토리지 복원.
+ */
+ protected function tearDown(): void
+ {
+ $this->restoreHookManager();
+ $this->restoreHookListenerRegistrar();
+ $this->restorePluginStorage();
+
+ parent::tearDown();
+ }
+
+ /**
+ * 플러그인 스토리지('plugins' 디스크)를 테스트 전용 임시 디렉토리로 격리한다.
+ *
+ * 설정 저장 테스트(PluginSettingsService::save)는 코어 'plugins' 디스크
+ * (root = storage_path('app/plugins'))에 setting.json 을 쓴다. 격리하지 않으면
+ * 테스트가 실제 로컬 런타임 설정 파일을 덮어써 검수 모드/자격증명이 오염된다
+ * (RefreshDatabase 는 DB 만 롤백하고 파일시스템은 되돌리지 않음). 디스크 root 를
+ * 임시 경로로 바꾸고 resolved 인스턴스를 purge 하여 실제 파일을 원천적으로 못
+ * 건드리게 한다. (회귀 배경: #458)
+ */
+ private function isolatePluginStorage(): void
+ {
+ // Laravel 이 테스트용 쓰기 공간으로 보장하는 storage/framework/testing 하위를 사용한다.
+ // sys_get_temp_dir() 은 CI/컨테이너/open_basedir 제약 환경에서 위치가 다르거나
+ // 쓰기 불가일 수 있어 프로젝트 내부의 격리 경로를 쓴다 (Laravel 규약 준수).
+ $this->isolatedPluginsRoot = storage_path(
+ 'framework/testing/plugin-storage-'.uniqid('', true)
+ );
+
+ File::ensureDirectoryExists($this->isolatedPluginsRoot);
+
+ config(['filesystems.disks.plugins.root' => $this->isolatedPluginsRoot]);
+
+ // 이미 resolve 된 'plugins' 디스크 인스턴스를 폐기해 새 root 로 재생성되게 한다.
+ Storage::forgetDisk('plugins');
+ }
+
+ /**
+ * 격리 임시 디렉토리를 제거한다 (테스트 간 잔여 파일 격리).
+ */
+ private function restorePluginStorage(): void
+ {
+ if ($this->isolatedPluginsRoot !== null && is_dir($this->isolatedPluginsRoot)) {
+ File::deleteDirectory($this->isolatedPluginsRoot);
+ }
+
+ $this->isolatedPluginsRoot = null;
+
+ Storage::forgetDisk('plugins');
+ }
+
+ /**
+ * HookManager static $hooks / $filters / $dispatching 를 스냅샷.
+ */
+ private function snapshotHookManager(): void
+ {
+ $ref = new \ReflectionClass(HookManager::class);
+ $this->hookSnapshot = [
+ 'hooks' => $ref->getProperty('hooks')->getValue(),
+ 'filters' => $ref->getProperty('filters')->getValue(),
+ 'dispatching' => $ref->getProperty('dispatching')->getValue(),
+ ];
+ }
+
+ /**
+ * 스냅샷 시점으로 HookManager 복원.
+ */
+ private function restoreHookManager(): void
+ {
+ if ($this->hookSnapshot === null) {
+ return;
+ }
+
+ $ref = new \ReflectionClass(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;
+ }
+
+ /**
+ * HookListenerRegistrar static $registered 를 스냅샷.
+ */
+ private function snapshotHookListenerRegistrar(): void
+ {
+ $ref = new \ReflectionClass(HookListenerRegistrar::class);
+ $prop = $ref->getProperty('registered');
+ $this->hookListenerRegistrarSnapshot = $prop->getValue();
+ }
+
+ /**
+ * 스냅샷 시점으로 HookListenerRegistrar::$registered 를 복원.
+ */
+ private function restoreHookListenerRegistrar(): void
+ {
+ if ($this->hookListenerRegistrarSnapshot === null) {
+ return;
+ }
+
+ $ref = new \ReflectionClass(HookListenerRegistrar::class);
+ $ref->getProperty('registered')->setValue(null, $this->hookListenerRegistrarSnapshot);
+
+ $this->hookListenerRegistrarSnapshot = null;
+ }
+
+ /**
+ * 마이그레이션 경로를 반환합니다.
+ *
+ * RefreshDatabase 의 migrate:fresh 명령에 코어 + 번들 확장 마이그레이션 경로를 전달합니다.
+ *
+ * @return array
+ */
+ 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,
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php
new file mode 100644
index 00000000..6d3718c9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php
@@ -0,0 +1,64 @@
+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());
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php
new file mode 100644
index 00000000..975f797e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php
@@ -0,0 +1,187 @@
+ 'acct', 'type' => 'sms', 'from' => '070', 'to' => '010',
+ 'refkey' => 'ref1', 'content' => ['sms' => ['message' => 'hi']],
+ ];
+
+ /**
+ * sendMessage 응답을 반환하는 ApiClient mock 을 만듭니다.
+ *
+ * @param array $result sendMessage 반환값
+ */
+ private function makeClient(array $result): BizppurioApiClient
+ {
+ $mock = Mockery::mock(BizppurioApiClient::class);
+ $mock->shouldReceive('sendMessage')->with($this->payload)->andReturn($result);
+ $mock->shouldReceive('isSuccess')
+ ->andReturnUsing(fn ($r) => (string) ($r['code'] ?? '') === '1000');
+
+ return $mock;
+ }
+
+ /**
+ * 컨테이너에서 실제 리포지토리(Provider 바인딩)를 해석해 반환.
+ */
+ private function dispatches(): BizppurioDispatchRepositoryInterface
+ {
+ return app(BizppurioDispatchRepositoryInterface::class);
+ }
+
+ /**
+ * refkey 로 pending 이력을 1건 seed.
+ */
+ private function seedPending(string $refkey): BizppurioDispatch
+ {
+ return BizppurioDispatch::create([
+ 'refkey' => $refkey,
+ 'channel' => 'sms',
+ 'to_number' => '01011112222',
+ 'content' => 'hi',
+ 'status' => DispatchStatus::Pending->value,
+ 'source' => 'auto',
+ ]);
+ }
+
+ public function test_성공시_예외없이_종료하고_이력을_sent로_갱신한다(): void
+ {
+ $dispatch = $this->seedPending('ref1');
+
+ $job = new SendMessageJob($this->payload, 'ref1');
+ $job->handle($this->makeClient(['code' => 1000, 'messagekey' => 'mk']), $this->dispatches());
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Sent, $dispatch->status);
+ $this->assertSame('mk', $dispatch->messagekey);
+ }
+
+ public function test_일시오류_결과코드는_예외를_던져_재시도한다(): void
+ {
+ $job = new SendMessageJob($this->payload, 'ref1');
+
+ $this->expectException(BizppurioApiException::class);
+
+ try {
+ $job->handle($this->makeClient(['code' => 5003, 'description' => 'temp']), $this->dispatches());
+ } catch (BizppurioApiException $e) {
+ $this->assertSame('5003', $e->getResultCode());
+ throw $e;
+ }
+ }
+
+ public function test_알림톡_일시오류_결과코드는_예외를_던져_재시도한다(): void
+ {
+ // 알림톡 일시오류(7306 카카오 시스템오류·7307 처리지연·7421 타임아웃·7437 요청실패)는
+ // 다시 보내면 성공할 수 있으므로 예외를 던져 큐가 재시도해야 한다.
+ foreach (['7306', '7307', '7421', '7437'] as $code) {
+ $job = new SendMessageJob($this->payload, 'ref1');
+
+ try {
+ $job->handle($this->makeClient(['code' => (int) $code, 'description' => 'temp']), $this->dispatches());
+ $this->fail("코드 {$code} 는 재시도(예외)여야 한다");
+ } catch (BizppurioApiException $e) {
+ $this->assertSame($code, $e->getResultCode(), "코드 {$code}");
+ }
+ }
+ }
+
+ public function test_영구실패_결과코드는_예외없이_종료하고_이력을_failed로_갱신한다(): void
+ {
+ $dispatch = $this->seedPending('ref1');
+
+ // 3006(계정오류) = 영구실패 → 재시도 안 함(예외 없음)
+ $job = new SendMessageJob($this->payload, 'ref1');
+ $job->handle($this->makeClient(['code' => 3006, 'description' => 'account error']), $this->dispatches());
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Failed, $dispatch->status);
+ $this->assertSame('3006', $dispatch->result_code);
+ }
+
+ public function test_tries와_backoff_기본값(): void
+ {
+ $job = new SendMessageJob($this->payload, 'ref1');
+
+ $this->assertSame(2, $job->tries);
+ $this->assertSame(2, $job->backoff());
+ }
+
+ public function test_after_commit_활성화(): void
+ {
+ $job = new SendMessageJob($this->payload, 'ref1');
+
+ $this->assertTrue($job->afterCommit);
+ }
+
+ public function test_최종실패_콜백은_pending이력을_failed로_마감하고_사유를_기록한다(): void
+ {
+ // 타임아웃·연결실패는 결과코드 없는 ConnectionException 으로 재시도 소진 후 failed() 로 온다.
+ $dispatch = $this->seedPending('ref1');
+
+ $job = new SendMessageJob($this->payload, 'ref1');
+ $job->failed(new ConnectionException('cURL error 28: Connection timed out'));
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Failed, $dispatch->status);
+ $this->assertNull($dispatch->result_code); // 전송 실패라 결과코드 없음
+ $this->assertStringContainsString('timed out', (string) $dispatch->result_message);
+ }
+
+ public function test_최종실패_콜백은_bizppurio_api_exception의_결과코드를_보존한다(): void
+ {
+ $dispatch = $this->seedPending('ref1');
+
+ $job = new SendMessageJob($this->payload, 'ref1');
+ $job->failed(new BizppurioApiException('일시 오류', resultCode: '5003'));
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Failed, $dispatch->status);
+ $this->assertSame('5003', $dispatch->result_code);
+ }
+
+ public function test_이미_확정된_이력은_최종실패_콜백이_덮어쓰지_않는다(): void
+ {
+ // webhook 이 먼저 success 로 확정한 뒤 failed() 가 늦게 불려도 멱등해야 한다.
+ $dispatch = $this->seedPending('ref1');
+ $dispatch->update(['status' => DispatchStatus::Success->value, 'result_code' => '4000']);
+
+ $job = new SendMessageJob($this->payload, 'ref1');
+ $job->failed(new ConnectionException('timed out'));
+
+ $dispatch->refresh();
+ $this->assertSame(DispatchStatus::Success, $dispatch->status);
+ $this->assertSame('4000', $dispatch->result_code);
+ }
+
+ public function test_이력이_없으면_최종실패_콜백은_아무것도_하지_않는다(): void
+ {
+ // 이력 seed 없이 호출 — 예외 없이 조용히 종료해야 한다.
+ $job = new SendMessageJob($this->payload, 'missing-ref');
+ $job->failed(new ConnectionException('timed out'));
+
+ $this->assertDatabaseMissing('bizppurio_dispatches', ['refkey' => 'missing-ref']);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php
new file mode 100644
index 00000000..eb099957
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/BalanceLowNotificationDataListenerTest.php
@@ -0,0 +1,127 @@
+listener = new BalanceLowNotificationDataListener;
+ }
+
+ private function emptyResult(): array
+ {
+ return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []];
+ }
+
+ /**
+ * 문자(sms) 잔액부족: channel_label="문자", result_code 주입 + 사이트 메타 채움.
+ */
+ public function test_injects_variables_for_sms_balance_low(): void
+ {
+ $out = $this->listener->injectBalanceLowData(
+ $this->emptyResult(),
+ 'bizppurio_balance_low',
+ ['9070', 'sms'],
+ );
+
+ $data = $out['data'];
+ $this->assertSame('9070', $data['result_code']);
+ $this->assertSame('문자', $data['channel_label']);
+ $this->assertSame('{recipient_name}', $data['name']);
+ $this->assertNotSame('', $data['app_name']);
+ $this->assertStringContainsString('/admin/plugins/sirsoft-message_bizppurio/settings', $data['settings_url']);
+ $this->assertArrayHasKey('site_url', $data);
+ }
+
+ /**
+ * 알림톡(alimtalk) 잔액부족: channel_label="알림톡", result_code=7436.
+ */
+ public function test_injects_variables_for_alimtalk_balance_low(): void
+ {
+ $out = $this->listener->injectBalanceLowData(
+ $this->emptyResult(),
+ 'bizppurio_balance_low',
+ ['7436', 'alimtalk'],
+ );
+
+ $this->assertSame('7436', $out['data']['result_code']);
+ $this->assertSame('알림톡', $out['data']['channel_label']);
+ }
+
+ /**
+ * lms 도 문자군으로 "문자" 라벨.
+ */
+ public function test_lms_maps_to_text_label(): void
+ {
+ $out = $this->listener->injectBalanceLowData(
+ $this->emptyResult(),
+ 'bizppurio_balance_low',
+ ['9071', 'lms'],
+ );
+
+ $this->assertSame('문자', $out['data']['channel_label']);
+ $this->assertSame('9071', $out['data']['result_code']);
+ }
+
+ /**
+ * 다른 알림 유형은 원본을 그대로 통과(변수 미주입).
+ */
+ public function test_ignores_other_notification_types(): void
+ {
+ $result = $this->emptyResult();
+ $out = $this->listener->injectBalanceLowData($result, 'order_confirmed', ['9070', 'sms']);
+
+ $this->assertSame($result, $out);
+ $this->assertArrayNotHasKey('result_code', $out['data']);
+ }
+
+ /**
+ * 알 수 없는 채널은 원본 문자열을 라벨로 유지(치환 실패보다 원본 노출).
+ */
+ public function test_unknown_channel_keeps_raw(): void
+ {
+ $out = $this->listener->injectBalanceLowData(
+ $this->emptyResult(),
+ 'bizppurio_balance_low',
+ ['9999', 'unknown'],
+ );
+
+ $this->assertSame('unknown', $out['data']['channel_label']);
+ }
+
+ /**
+ * 주입된 data 로 알림 본문 템플릿의 변수가 실제 값으로 치환된다(엔드투엔드).
+ */
+ public function test_injected_data_resolves_template_body(): void
+ {
+ $out = $this->listener->injectBalanceLowData(
+ $this->emptyResult(),
+ 'bizppurio_balance_low',
+ ['9071', 'alimtalk'],
+ );
+
+ $body = '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).';
+ $rendered = strtr($body, [
+ '{channel_label}' => $out['data']['channel_label'],
+ '{result_code}' => $out['data']['result_code'],
+ ]);
+
+ $this->assertSame('비즈뿌리오 잔액 부족으로 알림톡 발송이 실패했습니다 (코드: 9071).', $rendered);
+ $this->assertStringNotContainsString('{', $rendered);
+ }
+}
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php
new file mode 100644
index 00000000..ac2c241b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php
@@ -0,0 +1,95 @@
+listener = new GuestPhoneExtractListener;
+ }
+
+ /**
+ * isGuestOrder 판정과 배송지 orderer_phone 을 지정할 수 있는 Order 더블을 만듭니다.
+ *
+ * 리스너가 이커머스 관행대로 `$order->shippingAddress?->orderer_phone` 를 직접 읽으므로,
+ * shippingAddress 관계 접근자와 그 orderer_phone 속성을 함께 stub 한다. Eloquent 이벤트
+ * 재인스턴스화(new static)와 충돌하지 않도록 서브클래스 대신 Mockery partial mock 을 쓴다.
+ */
+ private function orderDouble(bool $guest, ?string $phone): Order
+ {
+ $order = \Mockery::mock(Order::class)->makePartial();
+ $order->shouldReceive('isGuestOrder')->andReturn($guest);
+
+ // 배송지 관계 접근(getAttribute('shippingAddress'))이 orderer_phone 을 가진 객체를 반환
+ $address = $phone === null ? null : (object) ['orderer_phone' => $phone];
+ $order->shouldReceive('getAttribute')->with('shippingAddress')->andReturn($address);
+
+ return $order;
+ }
+
+ private function makeResult(array $data = []): array
+ {
+ return ['notifiable' => null, 'notifiables' => null, 'data' => $data, 'context' => []];
+ }
+
+ public function test_비회원_주문알림에_전화번호가_주입된다(): void
+ {
+ $order = $this->orderDouble(guest: true, phone: '010-1234-5678');
+
+ $out = $this->listener->injectGuestPhone(
+ $this->makeResult(['name' => '홍길동']),
+ 'order_confirmed',
+ [$order],
+ );
+
+ $this->assertSame('010-1234-5678', $out['data'][SmsChannelDriver::RECIPIENT_PHONE_KEY]);
+ $this->assertSame('홍길동', $out['data']['name'], '기존 data 는 보존되어야 한다.');
+ }
+
+ public function test_회원_주문은_전화번호를_주입하지_않는다(): void
+ {
+ $order = $this->orderDouble(guest: false, phone: '010-1234-5678');
+
+ $out = $this->listener->injectGuestPhone($this->makeResult(), 'order_confirmed', [$order]);
+
+ $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']);
+ }
+
+ public function test_주문알림이_아니면_원본을_통과시킨다(): void
+ {
+ $order = $this->orderDouble(guest: true, phone: '010-1234-5678');
+
+ $out = $this->listener->injectGuestPhone($this->makeResult(['x' => 1]), 'inquiry_replied', [$order]);
+
+ $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']);
+ }
+
+ public function test_전화번호가_없으면_주입하지_않는다(): void
+ {
+ $order = $this->orderDouble(guest: true, phone: null);
+
+ $out = $this->listener->injectGuestPhone($this->makeResult(), 'order_shipped', [$order]);
+
+ $this->assertArrayNotHasKey(SmsChannelDriver::RECIPIENT_PHONE_KEY, $out['data']);
+ }
+
+ public function test_구독_훅과_filter_타입을_선언한다(): void
+ {
+ $hooks = GuestPhoneExtractListener::getSubscribedHooks();
+
+ $this->assertArrayHasKey('sirsoft-ecommerce.notification.extract_data', $hooks);
+ $this->assertSame('filter', $hooks['sirsoft-ecommerce.notification.extract_data']['type']);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php
new file mode 100644
index 00000000..419ad67d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php
@@ -0,0 +1,110 @@
+cache = new PluginCacheDriver(self::IDENTIFIER, config('cache.default'));
+ $this->cache->flush();
+ }
+
+ /**
+ * 리스너를 실제 훅 파이프라인에 등록합니다.
+ */
+ private function registerListener(): void
+ {
+ $tokenService = new BizppurioTokenService($this->cache, app(PluginSettingsService::class));
+ $listener = new InvalidateTokenOnSettingsSaveListener($tokenService);
+
+ HookManager::addAction(
+ 'core.plugin_settings.after_save',
+ [$listener, 'invalidateToken'],
+ 10,
+ );
+ }
+
+ public function test_저장_성공시_본_플러그인_캐시가_무효화된다(): void
+ {
+ $this->cache->put(self::CACHE_KEY, 'STALE_TOKEN', 3600);
+ $this->assertTrue($this->cache->has(self::CACHE_KEY));
+
+ $this->registerListener();
+
+ HookManager::doAction('core.plugin_settings.after_save', self::IDENTIFIER, ['bizppurio_id' => 'acct'], true);
+
+ $this->assertFalse($this->cache->has(self::CACHE_KEY));
+ }
+
+ public function test_저장_실패시_캐시를_건드리지_않는다(): void
+ {
+ $this->cache->put(self::CACHE_KEY, 'EXISTING_TOKEN', 3600);
+
+ $this->registerListener();
+
+ HookManager::doAction('core.plugin_settings.after_save', self::IDENTIFIER, ['bizppurio_id' => 'acct'], false);
+
+ $this->assertTrue($this->cache->has(self::CACHE_KEY));
+ $this->assertSame('EXISTING_TOKEN', $this->cache->get(self::CACHE_KEY));
+ }
+
+ public function test_다른_플러그인_저장시_캐시를_건드리지_않는다(): void
+ {
+ $this->cache->put(self::CACHE_KEY, 'EXISTING_TOKEN', 3600);
+
+ $this->registerListener();
+
+ HookManager::doAction('core.plugin_settings.after_save', 'sirsoft-other-plugin', [], true);
+
+ $this->assertTrue($this->cache->has(self::CACHE_KEY));
+ }
+
+ /**
+ * 실제 저장 API(PluginSettingsService::save) 를 호출해 코어 저장 경로 →
+ * after_save 훅 → 캐시 무효화까지 end-to-end 로 관찰한다.
+ */
+ public function test_설정_저장_API_호출시_실제_저장_경로를_통해_캐시가_무효화된다(): void
+ {
+ $this->cache->put(self::CACHE_KEY, 'STALE_TOKEN', 3600);
+
+ $this->registerListener();
+
+ app(PluginSettingsService::class)->save(self::IDENTIFIER, [
+ 'bizppurio_id' => 'new-account',
+ 'password' => 'new-secret',
+ 'is_test_mode' => true,
+ ]);
+
+ $this->assertFalse($this->cache->has(self::CACHE_KEY));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php
new file mode 100644
index 00000000..eb6d8a7a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php
@@ -0,0 +1,227 @@
+ $settings plugin 설정 키→값
+ */
+ private function makeListener(array $settings = []): RegisterNotificationChannelsListener
+ {
+ $stub = new class($settings) extends PluginSettingsService
+ {
+ /** @param array $map */
+ public function __construct(private array $map)
+ {
+ // 부모 생성자(의존성) 우회 — 테스트 stub 은 get()만 사용
+ }
+
+ public function get(string $identifier, ?string $key = null, mixed $default = null): mixed
+ {
+ return $this->map[$key] ?? $default;
+ }
+ };
+
+ return new RegisterNotificationChannelsListener($stub);
+ }
+
+ /** 완비된 설정값 */
+ private function fullSettings(): array
+ {
+ return [
+ 'bizppurio_id' => 'acme',
+ 'password' => 'secret',
+ 'sender_number' => '025550000',
+ 'api_key' => 'key-40',
+ 'sender_key' => 'sender-40',
+ ];
+ }
+
+ public function test_사용가능_채널에_sms와_alimtalk이_추가된다(): void
+ {
+ $listener = $this->makeListener();
+
+ $channels = $listener->addChannels([
+ ['id' => 'mail'],
+ ['id' => 'database'],
+ ]);
+
+ $ids = array_column($channels, 'id');
+ $this->assertContains('sms', $ids);
+ $this->assertContains('alimtalk', $ids);
+
+ $sms = collect($channels)->firstWhere('id', 'sms');
+ $this->assertTrue($sms['allow_guest'], 'sms 는 비회원 발송 허용(allow_guest:true)이어야 한다.');
+ $this->assertArrayHasKey('name_key', $sms);
+ $this->assertStringContainsString('sirsoft-message_bizppurio::', $sms['name_key']);
+
+ $alimtalk = collect($channels)->firstWhere('id', 'alimtalk');
+ $this->assertTrue($alimtalk['allow_guest']);
+ }
+
+ public function test_채널메타에_is_test_mode가_실린다(): void
+ {
+ // is_test_mode=true(검수) → 상태 배너 노출 기준. 채널 메타에 실려 프론트로 전달된다.
+ $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => true]));
+ $channels = $listener->addChannels([]);
+
+ $alimtalk = collect($channels)->firstWhere('id', 'alimtalk');
+ $this->assertTrue($alimtalk['is_test_mode'], '검수 모드면 채널 메타 is_test_mode=true.');
+
+ $sms = collect($channels)->firstWhere('id', 'sms');
+ $this->assertTrue($sms['is_test_mode']);
+ }
+
+ public function test_검수모드_해제시_is_test_mode가_false다(): void
+ {
+ $listener = $this->makeListener(array_merge($this->fullSettings(), ['is_test_mode' => false]));
+ $channels = $listener->addChannels([]);
+
+ $alimtalk = collect($channels)->firstWhere('id', 'alimtalk');
+ $this->assertFalse($alimtalk['is_test_mode'], '운영 모드면 is_test_mode=false.');
+ }
+
+ public function test_어떤_채널에도_uses_custom_list_플래그가_없다(): void
+ {
+ // Phase 6 재설계 A(⚑⚑ 결정 A): 알림톡 탭도 코어 기본 목록을 그대로 쓴다. 코어 목록을
+ // 숨기던 uses_custom_list 게이트·플래그는 폐기되었으므로 어느 채널에도 있으면 안 된다.
+ $channels = $this->makeListener()->addChannels([]);
+
+ $alimtalk = collect($channels)->firstWhere('id', 'alimtalk');
+ $this->assertArrayNotHasKey('uses_custom_list', $alimtalk, '알림톡은 코어 기본 목록을 쓰므로 플래그가 없어야 한다(재설계 A).');
+
+ $sms = collect($channels)->firstWhere('id', 'sms');
+ $this->assertArrayNotHasKey('uses_custom_list', $sms, 'sms 는 코어 기본 목록을 쓰므로 플래그가 없어야 한다.');
+ }
+
+ public function test_이미_존재하는_채널은_중복_추가되지_않는다(): void
+ {
+ $channels = $this->makeListener()->addChannels([['id' => 'sms', 'source' => 'other']]);
+
+ $smsCount = count(array_filter($channels, fn ($c) => $c['id'] === 'sms'));
+ $this->assertSame(1, $smsCount, 'sms 가 중복 추가되면 안 된다.');
+ }
+
+ public function test_readiness_sms는_필수설정_부족시_ready_false다(): void
+ {
+ $listener = $this->makeListener([]);
+
+ $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms');
+
+ $this->assertFalse($result['ready']);
+ $this->assertStringContainsString('readiness.sms_credentials_missing', $result['reason']);
+ }
+
+ public function test_readiness_sms는_발신번호만_없으면_해당_사유를_반환한다(): void
+ {
+ $listener = $this->makeListener([
+ 'bizppurio_id' => 'acme',
+ 'password' => 'secret',
+ ]);
+
+ $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms');
+
+ $this->assertFalse($result['ready']);
+ $this->assertStringContainsString('readiness.sms_sender_number_missing', $result['reason']);
+ }
+
+ public function test_readiness_sms는_완비시_ready_true다(): void
+ {
+ $listener = $this->makeListener($this->fullSettings());
+
+ $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'sms');
+
+ $this->assertTrue($result['ready']);
+ $this->assertNull($result['reason']);
+ }
+
+ public function test_readiness_alimtalk는_apikey나_senderkey_없으면_ready_false다(): void
+ {
+ $listener = $this->makeListener([
+ 'bizppurio_id' => 'acme',
+ 'password' => 'secret',
+ 'sender_number' => '025550000',
+ // api_key / sender_key 누락
+ ]);
+
+ $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'alimtalk');
+
+ $this->assertFalse($result['ready']);
+ $this->assertStringContainsString('readiness.alimtalk_api_key_missing', $result['reason']);
+ }
+
+ public function test_readiness_alimtalk는_완비시_ready_true다(): void
+ {
+ $listener = $this->makeListener($this->fullSettings());
+
+ $result = $listener->checkReadiness(['ready' => true, 'reason' => null], 'alimtalk');
+
+ $this->assertTrue($result['ready']);
+ }
+
+ public function test_readiness_우리채널이_아니면_원본_판정을_통과시킨다(): void
+ {
+ $listener = $this->makeListener([]);
+ $original = ['ready' => false, 'reason' => 'notification.readiness.mail_smtp_host_empty'];
+
+ $result = $listener->checkReadiness($original, 'mail');
+
+ $this->assertSame($original, $result, 'mail 등 타 채널 판정은 변형 없이 통과해야 한다.');
+ }
+
+ public function test_채널후보에_sms와_alimtalk이_더해진다(): void
+ {
+ $result = $this->makeListener()->addChannelCandidates(['mail'], 'welcome', null);
+
+ $this->assertContains('mail', $result);
+ $this->assertContains('sms', $result);
+ $this->assertContains('alimtalk', $result);
+ }
+
+ public function test_채널후보_중복은_제거된다(): void
+ {
+ $result = $this->makeListener()->addChannelCandidates(['sms'], 'welcome', null);
+
+ $this->assertSame(1, count(array_filter($result, fn ($c) => $c === 'sms')));
+ }
+
+ public function test_구독_훅에_3영역_channels가_포함된다(): void
+ {
+ $hooks = RegisterNotificationChannelsListener::getSubscribedHooks();
+
+ $this->assertArrayHasKey('core.notification.filter_available_channels', $hooks);
+ $this->assertArrayHasKey('core.notification.channel_readiness', $hooks);
+ $this->assertArrayHasKey('core.notification.channel_enabled', $hooks);
+ $this->assertArrayHasKey('core.auth.notification.channels', $hooks);
+ $this->assertArrayHasKey('sirsoft-ecommerce.notification.channels', $hooks);
+ $this->assertArrayHasKey('sirsoft-board.notification.channels', $hooks);
+
+ // 전부 filter 타입 명시(반환값 무시 회귀 차단)
+ foreach ($hooks as $config) {
+ $this->assertSame('filter', $config['type']);
+ }
+ }
+
+ public function test_channel_enabled_우리채널이_아니면_원본을_통과시킨다(): void
+ {
+ $listener = $this->makeListener();
+
+ // mail/database 등 타 채널은 저장소 조회 없이 코어 판정($enabled)을 그대로 반환
+ $this->assertTrue($listener->gateChannelEnabled(true, 'core', 'core', 'mail'));
+ $this->assertFalse($listener->gateChannelEnabled(false, 'core', 'core', 'database'));
+ $this->assertTrue($listener->gateChannelEnabled(true, 'module', 'sirsoft-board', 'slack'));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php
new file mode 100644
index 00000000..d4d7a57a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php
@@ -0,0 +1,149 @@
+ 'trigger_user']]): array
+ {
+ return [
+ 'welcome' => [
+ 'hook_prefix' => 'core.auth',
+ 'channels' => ['mail', 'database'],
+ 'templates' => [
+ ['channel' => 'mail', 'recipients' => $recipients, 'subject' => ['ko' => '제목'], 'body' => ['ko' => '{name}
']],
+ ['channel' => 'database', 'recipients' => $recipients, 'subject' => ['ko' => '알림'], 'body' => ['ko' => '{name}님 환영합니다']],
+ ],
+ ],
+ ];
+ }
+
+ public function test_회원_알림에_sms와_alimtalk_채널이_추가된다(): void
+ {
+ $result = $this->listener()->augment($this->coreDefinition());
+
+ $channels = $result['welcome']['channels'];
+ $this->assertContains('sms', $channels);
+ $this->assertContains('alimtalk', $channels);
+ $this->assertContains('mail', $channels, '기존 채널은 보존되어야 한다.');
+
+ $addedChannels = array_column($result['welcome']['templates'], 'channel');
+ $this->assertContains('sms', $addedChannels);
+ $this->assertContains('alimtalk', $addedChannels);
+ }
+
+ public function test_추가된_채널_body는_database_평문을_재활용한다(): void
+ {
+ $result = $this->listener()->augment($this->coreDefinition());
+
+ $alimtalk = collect($result['welcome']['templates'])->firstWhere('channel', 'alimtalk');
+ $this->assertSame('{name}님 환영합니다', $alimtalk['body']['ko'], 'database 평문 body 를 재활용해야 한다(HTML 아님).');
+ }
+
+ public function test_database가_없으면_mail_body의_htm_l을_제거해_쓴다(): void
+ {
+ $def = [
+ 'welcome' => [
+ 'channels' => ['mail'],
+ 'templates' => [
+ ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '{name}님
환영합니다
']],
+ ],
+ ],
+ ];
+
+ $result = $this->listener()->augment($def);
+
+ $sms = collect($result['welcome']['templates'])->firstWhere('channel', 'sms');
+ $this->assertSame('{name}님 환영합니다', $sms['body']['ko'], 'HTML 태그는 제거되고 변수는 보존되어야 한다.');
+ }
+
+ public function test_관리자_전용_알림은_증강되지_않는다(): void
+ {
+ $def = [
+ 'balance_low' => [
+ 'channels' => ['mail', 'database'],
+ 'templates' => [
+ ['channel' => 'mail', 'recipients' => [['type' => 'role', 'value' => 'admin']], 'body' => ['ko' => 'x']],
+ ],
+ ],
+ ];
+
+ $result = $this->listener()->augment($def);
+
+ $channels = $result['balance_low']['channels'];
+ $this->assertNotContains('sms', $channels, '관리자 전용 알림에는 문자/알림톡을 추가하지 않는다.');
+ $this->assertNotContains('alimtalk', $channels);
+ }
+
+ public function test_모듈형_순차배열도_증강한다(): void
+ {
+ // 모듈: getNotificationDefinitions() 는 순차 배열 [['type'=>..., ...], ...]
+ $def = [
+ [
+ 'type' => 'order_confirmed',
+ 'channels' => ['mail', 'database'],
+ 'templates' => [
+ ['channel' => 'database', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '주문 완료']],
+ ],
+ ],
+ ];
+
+ $result = $this->listener()->augment($def);
+
+ $this->assertContains('alimtalk', $result[0]['channels']);
+ $this->assertSame('order_confirmed', $result[0]['type'], 'type 등 기존 필드는 보존되어야 한다.');
+ }
+
+ public function test_이미_alimtalk이_있으면_중복_추가하지_않는다(): void
+ {
+ $def = [
+ 'welcome' => [
+ 'channels' => ['mail', 'alimtalk'],
+ 'templates' => [
+ ['channel' => 'mail', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => 'x']],
+ ['channel' => 'alimtalk', 'recipients' => [['type' => 'trigger_user']], 'body' => ['ko' => '기존']],
+ ],
+ ],
+ ];
+
+ $result = $this->listener()->augment($def);
+
+ $alimtalkCount = count(array_filter($result['welcome']['templates'], fn ($t) => $t['channel'] === 'alimtalk'));
+ $this->assertSame(1, $alimtalkCount, 'alimtalk template 이 중복 추가되면 안 된다.');
+ }
+
+ public function test_recipients가_없는_알림은_회원_대상으로_보고_증강한다(): void
+ {
+ $def = [
+ 'generic' => [
+ 'channels' => ['mail'],
+ 'templates' => [
+ ['channel' => 'mail', 'body' => ['ko' => '알림']],
+ ],
+ ],
+ ];
+
+ $result = $this->listener()->augment($def);
+
+ $this->assertContains('alimtalk', $result['generic']['channels']);
+ // recipients 미지정 → 기본 trigger_user
+ $alimtalk = collect($result['generic']['templates'])->firstWhere('channel', 'alimtalk');
+ $this->assertSame('trigger_user', $alimtalk['recipients'][0]['type']);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php
new file mode 100644
index 00000000..f0ef37f7
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php
@@ -0,0 +1,90 @@
+ 'r1',
+ 'channel' => 'lms',
+ 'to_number' => '01011112222',
+ 'content' => 'x',
+ 'status' => 'pending',
+ 'source' => 'auto',
+ ]);
+
+ $this->assertSame(DispatchChannel::Lms, $dispatch->channel);
+ $this->assertSame(DispatchStatus::Pending, $dispatch->status);
+ }
+
+ /**
+ * 마스킹 접근자는 가운데 4자리를 가린다.
+ */
+ public function test_masked_number(): void
+ {
+ $dispatch = new BizppurioDispatch(['to_number' => '01012345678']);
+ $this->assertSame('010-****-5678', $dispatch->masked_number);
+ }
+
+ /**
+ * 하이픈 포함 번호도 정규화 후 마스킹한다.
+ */
+ public function test_masked_number_with_hyphens(): void
+ {
+ $dispatch = new BizppurioDispatch(['to_number' => '010-1234-5678']);
+ $this->assertSame('010-****-5678', $dispatch->masked_number);
+ }
+
+ /**
+ * to_user_id 로 회원 관계를 로드한다.
+ */
+ public function test_user_relation(): void
+ {
+ $user = User::factory()->create();
+ $dispatch = BizppurioDispatch::create([
+ 'refkey' => 'r2',
+ 'channel' => 'sms',
+ 'to_number' => '01011112222',
+ 'to_user_id' => $user->id,
+ 'content' => 'x',
+ 'status' => 'sent',
+ 'source' => 'auto',
+ ]);
+
+ $this->assertNotNull($dispatch->user);
+ $this->assertSame($user->id, $dispatch->user->id);
+ }
+
+ /**
+ * byRefkey 스코프로 조회한다.
+ */
+ public function test_by_refkey_scope(): void
+ {
+ BizppurioDispatch::create([
+ 'refkey' => 'unique_ref',
+ 'channel' => 'sms',
+ 'to_number' => '01000000000',
+ 'content' => 'x',
+ 'status' => 'sent',
+ 'source' => 'auto',
+ ]);
+
+ $found = BizppurioDispatch::query()->byRefkey('unique_ref')->first();
+ $this->assertNotNull($found);
+ $this->assertSame('unique_ref', $found->refkey);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php
new file mode 100644
index 00000000..00ee566a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php
@@ -0,0 +1,135 @@
+repo = new BizppurioDispatchRepository;
+ }
+
+ private function makeDispatch(array $overrides = []): BizppurioDispatch
+ {
+ return $this->repo->create(array_merge([
+ 'refkey' => 'r'.uniqid(),
+ 'channel' => 'sms',
+ 'to_number' => '01011112222',
+ 'content' => 'x',
+ 'status' => 'sent',
+ 'source' => 'auto',
+ 'sent_at' => now(),
+ ], $overrides));
+ }
+
+ public function test_create_and_find_by_refkey(): void
+ {
+ $this->makeDispatch(['refkey' => 'known']);
+ $this->assertNotNull($this->repo->findByRefkey('known'));
+ $this->assertNull($this->repo->findByRefkey('missing'));
+ }
+
+ public function test_update(): void
+ {
+ $dispatch = $this->makeDispatch(['refkey' => 'upd', 'status' => 'sent']);
+ $this->repo->update($dispatch, ['status' => 'success', 'result_code' => '4100']);
+
+ $this->assertSame('success', $dispatch->fresh()->status->value);
+ $this->assertSame('4100', $dispatch->fresh()->result_code);
+ }
+
+ public function test_paginate_filters_by_channel_and_status(): void
+ {
+ $this->makeDispatch(['channel' => 'sms', 'status' => 'success']);
+ $this->makeDispatch(['channel' => 'alimtalk', 'status' => 'failed']);
+ $this->makeDispatch(['channel' => 'sms', 'status' => 'failed']);
+
+ $smsPage = $this->repo->paginate(['channel' => 'sms']);
+ $this->assertSame(2, $smsPage->total());
+
+ $failedPage = $this->repo->paginate(['status' => 'failed']);
+ $this->assertSame(2, $failedPage->total());
+ }
+
+ public function test_paginate_keyword_matches_number_or_refkey(): void
+ {
+ $this->makeDispatch(['refkey' => 'refABC', 'to_number' => '01099998888']);
+ $this->makeDispatch(['refkey' => 'other', 'to_number' => '01000000000']);
+
+ $byRefkey = $this->repo->paginate(['keyword' => 'refABC']);
+ $this->assertSame(1, $byRefkey->total());
+
+ $byNumber = $this->repo->paginate(['keyword' => '9999']);
+ $this->assertSame(1, $byNumber->total());
+ }
+
+ public function test_link_notification_log_by_refkey(): void
+ {
+ $this->makeDispatch(['refkey' => 'to_link']);
+
+ $linked = $this->repo->linkNotificationLog('to_link', 777);
+ $this->assertTrue($linked);
+ $this->assertSame(777, (int) $this->repo->findByRefkey('to_link')->notification_log_id);
+ }
+
+ public function test_link_notification_log_missing_refkey_returns_false(): void
+ {
+ $this->assertFalse($this->repo->linkNotificationLog('nope', 1));
+ }
+
+ public function test_find_by_notification_log_ids_keyed(): void
+ {
+ $this->makeDispatch(['refkey' => 'a', 'notification_log_id' => 10]);
+ $this->makeDispatch(['refkey' => 'b', 'notification_log_id' => 20]);
+ $this->makeDispatch(['refkey' => 'c', 'notification_log_id' => null]); // 미연결
+
+ $map = $this->repo->findByNotificationLogIdsKeyed([10, 20, 30]);
+
+ $this->assertCount(2, $map);
+ $this->assertSame('a', $map[10]->refkey);
+ $this->assertSame('b', $map[20]->refkey);
+ $this->assertFalse($map->has(30));
+ }
+
+ public function test_find_by_notification_log_ids_empty_returns_empty(): void
+ {
+ $this->assertTrue($this->repo->findByNotificationLogIdsKeyed([])->isEmpty());
+ }
+
+ public function test_recent_linked_keyed_excludes_unlinked_and_keys_by_log_id(): void
+ {
+ $this->makeDispatch(['refkey' => 'x', 'notification_log_id' => 11]);
+ $this->makeDispatch(['refkey' => 'y', 'notification_log_id' => 22]);
+ $this->makeDispatch(['refkey' => 'z', 'notification_log_id' => null]); // 미연결 → 제외
+
+ $map = $this->repo->recentLinkedKeyed();
+
+ $this->assertCount(2, $map);
+ $this->assertSame('x', $map[11]->refkey);
+ $this->assertSame('y', $map[22]->refkey);
+ }
+
+ public function test_recent_linked_keyed_respects_limit(): void
+ {
+ foreach (range(1, 5) as $i) {
+ $this->makeDispatch(['refkey' => 'r'.$i, 'notification_log_id' => $i]);
+ }
+
+ // 최신(id 큰) 것부터 limit 만큼 — 최근 2건만
+ $map = $this->repo->recentLinkedKeyed(2);
+
+ $this->assertCount(2, $map);
+ $this->assertTrue($map->has(5));
+ $this->assertTrue($map->has(4));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php
new file mode 100644
index 00000000..88c0be33
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php
@@ -0,0 +1,67 @@
+repo = new BizppurioNotificationBindingRepository;
+ }
+
+ public function test_upsert_creates_then_updates(): void
+ {
+ $created = $this->repo->upsert('welcome', 'alimtalk', [
+ 'template_code' => 'TW_1',
+ 'template_name' => '가입환영',
+ 'fallback_sms_enabled' => true,
+ ]);
+ $this->assertSame('TW_1', $created->template_code);
+
+ // 같은 (type, channel) 재저장 → 갱신(중복 생성 아님)
+ $updated = $this->repo->upsert('welcome', 'alimtalk', [
+ 'template_code' => 'TW_2',
+ 'template_name' => '가입환영v2',
+ 'fallback_sms_enabled' => false,
+ ]);
+
+ $this->assertSame($created->id, $updated->id);
+ $this->assertSame('TW_2', $updated->template_code);
+ $this->assertDatabaseCount('bizppurio_notification_bindings', 1);
+ }
+
+ public function test_find_active_only_returns_active(): void
+ {
+ $this->repo->upsert('welcome', 'alimtalk', [
+ 'template_code' => 'TW_1',
+ 'template_name' => 'n',
+ 'is_active' => true,
+ ]);
+
+ $this->assertNotNull($this->repo->findActive('welcome'));
+
+ $this->repo->upsert('welcome', 'alimtalk', ['is_active' => false, 'template_code' => 'TW_1', 'template_name' => 'n']);
+ $this->assertNull($this->repo->findActive('welcome'));
+ }
+
+ public function test_delete(): void
+ {
+ $this->repo->upsert('order_confirmed', 'alimtalk', [
+ 'template_code' => 'TW_9',
+ 'template_name' => '주문완료',
+ ]);
+
+ $this->repo->delete('order_confirmed', 'alimtalk');
+ $this->assertNull($this->repo->findActive('order_confirmed'));
+ $this->assertDatabaseCount('bizppurio_notification_bindings', 0);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php
new file mode 100644
index 00000000..563b8297
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php
@@ -0,0 +1,535 @@
+ $rendered replaceVariables 반환값
+ */
+ private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '{name} 님 주문이 완료되었습니다.']): NotificationTemplate
+ {
+ $template = Mockery::mock(NotificationTemplate::class)->makePartial();
+ $template->is_active = true;
+ $template->shouldReceive('replaceVariables')->andReturn($rendered);
+
+ return $template;
+ }
+
+ /**
+ * findActive 반환값을 지정한 binding 리포지토리 mock 을 만듭니다.
+ *
+ * @param BizppurioNotificationBinding|null $binding findActive 반환값
+ */
+ private function fakeBindings(?BizppurioNotificationBinding $binding): BizppurioNotificationBindingRepositoryInterface
+ {
+ $repo = Mockery::mock(BizppurioNotificationBindingRepositoryInterface::class);
+ $repo->shouldReceive('findActive')
+ ->with(Mockery::any(), 'alimtalk')
+ ->andReturn($binding);
+
+ return $repo;
+ }
+
+ /**
+ * 연결(binding) 모델을 만듭니다.
+ */
+ private function binding(bool $fallback = false): BizppurioNotificationBinding
+ {
+ $binding = new BizppurioNotificationBinding;
+ $binding->notification_type = 'order_confirmed';
+ $binding->channel = 'alimtalk';
+ $binding->template_code = 'TW_1234';
+ $binding->template_name = '주문완료';
+ $binding->fallback_sms_enabled = $fallback;
+ $binding->is_active = true;
+
+ return $binding;
+ }
+
+ /**
+ * 카카오 상세조회 내용을 반환하는 KakaoTemplateContentResolver mock 을 만듭니다.
+ *
+ * @param array|null $content resolve 반환값 (null=조회 실패 → skip)
+ */
+ private function fakeKakaoContent(?array $content): KakaoTemplateContentResolver
+ {
+ $resolver = Mockery::mock(KakaoTemplateContentResolver::class);
+ $resolver->shouldReceive('resolve')->andReturn($content);
+
+ return $resolver;
+ }
+
+ /**
+ * 기본 카카오 상세(본문만 있는 단순 승인 템플릿).
+ *
+ * @return array
+ */
+ private function kakaoContent(string $templateContent = '#{name}님 주문이 완료되었습니다.'): array
+ {
+ return ['templateCode' => 'TW_1234', 'templateContent' => $templateContent];
+ }
+
+ /**
+ * 템플릿·binding·빌더를 조합한 driver 를 만듭니다.
+ *
+ * @param array|null $kakao 카카오 상세조회 내용 (null=조회 실패)
+ * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용)
+ */
+ private function makeDriver(
+ ?NotificationTemplate $template,
+ ?BizppurioNotificationBinding $binding,
+ ?MessagePayloadBuilder $builder = null,
+ array|false|null $kakao = false,
+ bool $isTestMode = true,
+ ): AlimtalkChannelDriver {
+ $templateService = Mockery::mock(NotificationTemplateService::class);
+ $templateService->shouldReceive('resolve')
+ ->with(Mockery::any(), 'alimtalk')
+ ->andReturn($template);
+
+ // $kakao 기본값 false = "기본 카카오 내용 제공"(대부분 테스트). null = 조회 실패.
+ $content = $kakao === false ? $this->kakaoContent() : $kakao;
+
+ $pluginSettings = Mockery::mock(PluginSettingsService::class);
+ $pluginSettings->shouldReceive('get')
+ ->with('sirsoft-message_bizppurio', 'is_test_mode', true)
+ ->andReturn($isTestMode);
+
+ // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백.
+ $definitionService = Mockery::mock(NotificationDefinitionService::class);
+ $definitionService->shouldReceive('resolve')->andReturn(null);
+
+ return new AlimtalkChannelDriver(
+ $templateService,
+ $definitionService,
+ $this->fakeBindings($binding),
+ $builder ?? $this->spyBuilder(),
+ new BizppurioDispatchRepository,
+ new DispatchLinkContext,
+ $this->fakeKakaoContent($content),
+ new AlimtalkPayloadMapper,
+ $pluginSettings,
+ );
+ }
+
+ /**
+ * buildAlimtalk 호출 인자를 기록하는 payload 빌더 스파이를 만듭니다.
+ */
+ private function spyBuilder(): MessagePayloadBuilder
+ {
+ return new class extends MessagePayloadBuilder
+ {
+ /** @var array> */
+ public array $calls = [];
+
+ public function __construct() {}
+
+ public function buildAlimtalk(
+ string $to,
+ string $templateCode,
+ string $message,
+ string $refkey,
+ array $extra = [],
+ ): array {
+ $this->calls[] = [
+ 'to' => $to,
+ 'templateCode' => $templateCode,
+ 'message' => $message,
+ 'refkey' => $refkey,
+ ];
+
+ return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'templatecode' => $templateCode];
+ }
+ };
+ }
+
+ private function notification(array $data = ['name' => '김철수']): GenericNotification
+ {
+ return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce');
+ }
+
+ public function test_연결된_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ // binding = null → 미연결 → NotificationSendSkippedException(코어가 실패로 기록하도록)
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver($this->fakeTemplate(), null)->send($member, $this->notification());
+ } finally {
+ Bus::assertNotDispatched(SendMessageJob::class);
+ $this->assertDatabaseCount('bizppurio_dispatches', 0);
+ }
+ }
+
+ public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $definition = Mockery::mock(\App\Models\NotificationDefinition::class);
+ $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영');
+
+ $definitionService = Mockery::mock(\App\Services\NotificationDefinitionService::class);
+ $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition);
+
+ $templateService = Mockery::mock(\App\Services\NotificationTemplateService::class);
+ $templateService->shouldReceive('resolve')->with(Mockery::any(), 'alimtalk')->andReturn(null);
+
+ $pluginSettings = Mockery::mock(\App\Services\PluginSettingsService::class);
+ $pluginSettings->shouldReceive('get')->andReturn(true);
+
+ $driver = new AlimtalkChannelDriver(
+ $templateService,
+ $definitionService,
+ $this->fakeBindings(null),
+ $this->spyBuilder(),
+ new BizppurioDispatchRepository,
+ new DispatchLinkContext,
+ $this->fakeKakaoContent($this->kakaoContent()),
+ new AlimtalkPayloadMapper,
+ $pluginSettings,
+ );
+
+ try {
+ $driver->send($member, $this->notification());
+ $this->fail('NotificationSendSkippedException 가 발생해야 한다.');
+ } catch (NotificationSendSkippedException $e) {
+ $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.');
+ $this->assertStringNotContainsString('order_confirmed', $e->getMessage());
+ }
+ }
+
+ public function test_카카오_템플릿_내용_조회_실패시_예외를_던지고_발송하지_않는다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ // binding 은 있으나 카카오 상세조회 실패(고아·장애) → 알림톡 본문 소스 없음
+ // → NotificationSendSkippedException(코어가 실패로 기록하도록)
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver($this->fakeTemplate(), $this->binding(), kakao: null)
+ ->send($member, $this->notification());
+ } finally {
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+ }
+
+ public function test_카카오_치환_결과_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ // 카카오 templateContent 가 치환 후에도 빈 문자열이면 발송 불가
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver(
+ $this->fakeTemplate(),
+ $this->binding(),
+ kakao: ['templateCode' => 'TW_1234', 'templateContent' => ' '],
+ )->send($member, $this->notification());
+ } finally {
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+ }
+
+ public function test_코어_alimtalk_템플릿이_없어도_카카오_내용으로_발송한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ // 알림톡 본문은 카카오에서 오므로, 코어 alimtalk 템플릿(SMS 대체용)이 없어도 발송돼야 한다.
+ // 대체발송 OFF 이면 코어 템플릿을 아예 조회하지 않는다.
+ $this->makeDriver(null, $this->binding(fallback: false))->send($member, $this->notification());
+
+ Bus::assertDispatched(SendMessageJob::class);
+ }
+
+ public function test_회원은_mobile로_연결된_템플릿코드로_발송한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+ $builder = $this->spyBuilder();
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder)
+ ->send($member, $this->notification());
+
+ Bus::assertDispatched(SendMessageJob::class);
+ $this->assertCount(1, $builder->calls);
+ $this->assertSame('01012345678', $builder->calls[0]['to']);
+ $this->assertSame('TW_1234', $builder->calls[0]['templateCode'], '연결된 카카오 템플릿 코드로 발송해야 한다.');
+ }
+
+ public function test_비회원은_data의_전화번호로_발송한다(): void
+ {
+ Bus::fake();
+ $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
+ $data = ['name' => '홍길동', AlimtalkChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
+ $builder = $this->spyBuilder();
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder)
+ ->send($guest, $this->notification($data));
+
+ Bus::assertDispatched(SendMessageJob::class);
+ $this->assertSame('01099990000', $builder->calls[0]['to']);
+ }
+
+ public function test_카카오_본문의_변수를_알림_data로_치환해_발송한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+ $builder = $this->spyBuilder();
+
+ // 알림톡 본문은 카카오 승인 템플릿(#{var})에서 오고, 발송 시 알림 data 로 치환된다.
+ $this->makeDriver(
+ $this->fakeTemplate(),
+ $this->binding(),
+ $builder,
+ kakao: ['templateCode' => 'TW_1234', 'templateContent' => '#{name}님 #{order_number} 주문 완료'],
+ )->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1']));
+
+ $this->assertSame(
+ '김철수님 A1 주문 완료',
+ $builder->calls[0]['message'],
+ '카카오 본문의 #{var} 를 알림 data 로 치환해야 한다.',
+ );
+ }
+
+ public function test_카카오_버튼을_발송_extra로_전달한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+
+ // 버튼 URL 변수까지 치환돼 payload button 에 실려야 한다(회귀: 이전엔 버튼 자체가 누락).
+ $builder = new class extends MessagePayloadBuilder
+ {
+ /** @var array> */
+ public array $extras = [];
+
+ public function __construct() {}
+
+ public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array
+ {
+ $this->extras[] = $extra;
+
+ return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => array_merge(['message' => $message], $extra)]];
+ }
+ };
+
+ $this->makeDriver(
+ $this->fakeTemplate(),
+ $this->binding(),
+ $builder,
+ kakao: [
+ 'templateCode' => 'TW_1234',
+ 'templateContent' => '#{name}님 주문 완료',
+ 'buttons' => [
+ ['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/orders/#{order_number}'],
+ ],
+ ],
+ )->send($member, $this->notification(['name' => '김철수', 'order_number' => 'A1']));
+
+ $button = $builder->extras[0]['button'][0];
+ $this->assertSame('WL', $button['type']);
+ $this->assertSame('https://m.shop/orders/A1', $button['url_mobile'], '버튼 URL 변수도 치환돼 발송돼야 한다.');
+ }
+
+ public function test_대체발송_o_n이면_payload에_sms_resend가_병합된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+
+ // 실제 payload 병합을 관찰하려면 실 빌더가 필요하므로, buildAlimtalk 만 최소 stub 하고
+ // withSmsFallback 결과를 dispatch 된 Job payload 로 검증한다.
+ $builder = new class extends MessagePayloadBuilder
+ {
+ public function __construct() {}
+
+ public function buildAlimtalk(string $to, string $templateCode, string $message, string $refkey, array $extra = []): array
+ {
+ return ['type' => 'at', 'to' => $to, 'refkey' => $refkey, 'content' => ['at' => ['message' => $message]]];
+ }
+ };
+
+ // replaceVariables 는 치환 완료본을 반환하므로 mock body 도 치환 완료 텍스트로 준다.
+ // 드라이버는 이 body 를 알림톡 본문(#{var} 변환)과 대체 SMS 본문(원문 그대로) 두 곳에 쓴다.
+ $this->makeDriver(
+ $this->fakeTemplate(['subject' => '', 'body' => '김철수 님 주문 완료']),
+ $this->binding(fallback: true),
+ $builder,
+ )->send($member, $this->notification());
+
+ Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) {
+ $this->assertSame(['first' => 'sms'], $job->payload['resend'] ?? null, '대체발송 ON 은 resend:{first:sms} 를 넣어야 한다.');
+ $this->assertArrayHasKey('recontent', $job->payload);
+ $this->assertSame('김철수 님 주문 완료', $job->payload['recontent']['sms']['message'] ?? null, '대체 SMS 본문은 치환 완료된 코어 본문(#{var} 미변환)이어야 한다.');
+
+ return true;
+ });
+ }
+
+ public function test_대체발송_of_f이면_resend가_없다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding(fallback: false))
+ ->send($member, $this->notification());
+
+ Bus::assertDispatched(SendMessageJob::class, function (SendMessageJob $job) {
+ $this->assertArrayNotHasKey('resend', $job->payload);
+
+ return true;
+ });
+ }
+
+ public function test_회원_발송_시_pending_이력을_alimtalk_채널로_생성한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']);
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification());
+
+ $this->assertDatabaseCount('bizppurio_dispatches', 1);
+ $dispatch = BizppurioDispatch::first();
+ $this->assertSame('pending', $dispatch->status->value);
+ $this->assertSame('alimtalk', $dispatch->channel->value);
+ $this->assertSame($member->id, $dispatch->to_user_id);
+ $this->assertSame('order_confirmed', $dispatch->notification_type);
+ }
+
+ public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+ $builder = $this->spyBuilder();
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding(), $builder)
+ ->send($member, $this->notification());
+
+ $dispatch = BizppurioDispatch::first();
+ $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.');
+ $this->assertSame('TW_1234', $dispatch->request_payload['templatecode'] ?? null);
+ }
+
+ public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외
+ // 규칙(to/refkey/type/message 제거)을 검증할 수 있다.
+ $pluginSettings = Mockery::mock(PluginSettingsService::class);
+ $pluginSettings->shouldReceive('get')->andReturn('');
+ $realBuilder = new MessagePayloadBuilder($pluginSettings);
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding(), $realBuilder)
+ ->send($member, $this->notification());
+
+ $dispatch = BizppurioDispatch::first();
+ $payload = $dispatch->request_payload;
+
+ $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertArrayNotHasKey('message', $payload['content']['at'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertArrayHasKey('templatecode', $payload['content']['at'] ?? [], 'templatecode 는 다른 컬럼에 없으므로 남아있어야 한다.');
+ }
+
+ public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void
+ {
+ Bus::fake();
+ $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
+
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver($this->fakeTemplate(), $this->binding())
+ ->send($guest, $this->notification(['name' => '홍길동']));
+ } finally {
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+ }
+
+ public function test_generic_notification이_아니면_무시한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, new Notification);
+
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+
+ public function test_정상_조건에서_발송하고_이력을_생성한다(): void
+ {
+ // 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다.
+ // 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다.
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding())->send($member, $this->notification());
+
+ Bus::assertDispatched(SendMessageJob::class);
+ $this->assertDatabaseCount('bizppurio_dispatches', 1);
+ }
+
+ public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: true)
+ ->send($member, $this->notification());
+
+ $this->assertTrue(BizppurioDispatch::first()->is_test_mode);
+ }
+
+ public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $this->makeDriver($this->fakeTemplate(), $this->binding(), isTestMode: false)
+ ->send($member, $this->notification());
+
+ $this->assertFalse(BizppurioDispatch::first()->is_test_mode);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php
new file mode 100644
index 00000000..750f9c43
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php
@@ -0,0 +1,275 @@
+mapper()->map(
+ ['templateContent' => '#{name}님 주문 #{order_no} 완료'],
+ ['name' => '홍길동', 'order_no' => 'A123'],
+ );
+
+ $this->assertSame('홍길동님 주문 A123 완료', $result['message']);
+ }
+
+ public function test_웹링크_버튼을_발송형식으로_변환하고_url변수를_치환한다(): void
+ {
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'buttons' => [
+ [
+ 'name' => '주문조회',
+ 'linkType' => 'WL',
+ 'linkMo' => 'https://m.shop/orders/#{order_no}',
+ 'linkPc' => 'https://shop/orders/#{order_no}',
+ ],
+ ],
+ ],
+ ['order_no' => 'A123'],
+ );
+
+ $button = $result['extra']['button'][0];
+ $this->assertSame('주문조회', $button['name']);
+ $this->assertSame('WL', $button['type']);
+ $this->assertSame('https://m.shop/orders/A123', $button['url_mobile']);
+ $this->assertSame('https://shop/orders/A123', $button['url_pc']);
+ // 카카오 필드명(linkType/linkMo)은 발송 payload 에 남지 않아야 한다.
+ $this->assertArrayNotHasKey('linkType', $button);
+ $this->assertArrayNotHasKey('linkMo', $button);
+ }
+
+ public function test_앱링크_전화_플러그인_버튼_필드를_매핑한다(): void
+ {
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'buttons' => [
+ ['name' => '앱', 'linkType' => 'AL', 'linkAnd' => 'myapp://a', 'linkIos' => 'myapp://i'],
+ ['name' => '전화', 'linkType' => 'TN', 'telNumber' => '1588-0000'],
+ ['name' => '플러그인', 'linkType' => 'P1', 'pluginId' => 'PLUG_1'],
+ ],
+ ],
+ [],
+ );
+
+ $buttons = $result['extra']['button'];
+ $this->assertSame('myapp://a', $buttons[0]['scheme_android']);
+ $this->assertSame('myapp://i', $buttons[0]['scheme_ios']);
+ $this->assertSame('1588-0000', $buttons[1]['tel_number']);
+ $this->assertSame('PLUG_1', $buttons[2]['plugin_id']);
+ }
+
+ public function test_quick_replies를_quickreply로_변환한다(): void
+ {
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'quickReplies' => [
+ ['name' => '바로가기', 'linkType' => 'WL', 'linkMo' => 'https://m.shop'],
+ ],
+ ],
+ [],
+ );
+
+ $qr = $result['extra']['quickreply'][0];
+ $this->assertSame('바로가기', $qr['name']);
+ $this->assertSame('WL', $qr['type']);
+ $this->assertSame('https://m.shop', $qr['url_mobile']);
+ }
+
+ public function test_title_header를_치환해_매핑한다(): void
+ {
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'templateTitle' => '#{name}님',
+ 'templateHeader' => '주문 안내',
+ ],
+ ['name' => '홍길동'],
+ );
+
+ $this->assertSame('홍길동님', $result['extra']['title']);
+ $this->assertSame('주문 안내', $result['extra']['header']);
+ }
+
+ public function test_item과_itemhighlight를_매핑하고_치환한다(): void
+ {
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'templateItem' => [
+ 'list' => [
+ ['title' => '상품', 'description' => '#{product}'],
+ ],
+ 'summary' => ['title' => '합계', 'description' => '#{total}원'],
+ ],
+ 'templateItemHighlight' => ['title' => '#{name}님', 'description' => '주문완료'],
+ ],
+ ['product' => '티셔츠', 'total' => '20,000', 'name' => '홍길동'],
+ );
+
+ $this->assertSame('티셔츠', $result['extra']['item']['list'][0]['description']);
+ $this->assertSame('20,000원', $result['extra']['item']['summary']['description']);
+ $this->assertSame('홍길동님', $result['extra']['itemhighlight']['title']);
+ }
+
+ public function test_대표링크를_link로_변환하고_치환한다(): void
+ {
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'templateRepresentLink' => [
+ 'linkMo' => 'https://m.shop/#{id}',
+ 'linkPc' => 'https://shop/#{id}',
+ ],
+ ],
+ ['id' => 'X1'],
+ );
+
+ $this->assertSame('https://m.shop/X1', $result['extra']['link']['url_mobile']);
+ $this->assertSame('https://shop/X1', $result['extra']['link']['url_pc']);
+ }
+
+ public function test_원문에_프로토콜_접두어가_있고_변수값도_완전url이면_중복을_제거한다(): void
+ {
+ // 결함② — 카카오 콘솔에 `http://#{action_url}` 로 등록된 버튼. action_url 자체가
+ // config('app.url') 기반 완전 URL(https://...)이라 단순 치환 시 프로토콜이 중복된다.
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'buttons' => [
+ ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{action_url}'],
+ ],
+ ],
+ ['action_url' => 'https://ehkim.gnuboard.net/login'],
+ );
+
+ $this->assertSame(
+ 'https://ehkim.gnuboard.net/login',
+ $result['extra']['button'][0]['url_mobile'],
+ '원문 접두어(http://)를 제거해 프로토콜 중복(http://https://...)을 방지해야 한다.',
+ );
+ }
+
+ public function test_원문_접두어가_https이고_변수값도_https이면_중복을_제거한다(): void
+ {
+ // 실제 카카오 콘솔 등록값(관리자 화면 상세조회 스크린샷 확인, 2026-07-24) —
+ // "회원가입 환영" 템플릿 버튼이 http:// 가 아니라 https://#{action_url} 로 등록돼 있다.
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'buttons' => [
+ ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'https://#{action_url}'],
+ ],
+ ],
+ ['action_url' => 'https://ehkim.gnuboard.net/login'],
+ );
+
+ $this->assertSame(
+ 'https://ehkim.gnuboard.net/login',
+ $result['extra']['button'][0]['url_mobile'],
+ '원문 접두어가 https:// 인 경우도 동일하게 중복(https://https://...)을 방지해야 한다.',
+ );
+ }
+
+ public function test_원문에_프로토콜_접두어가_없으면_변수값을_그대로_둔다(): void
+ {
+ // 가이드 문서 원안(#{action_url} 만 등록)대로면 애초에 중복이 없으므로 손대지 않는다.
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'buttons' => [
+ ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => '#{action_url}'],
+ ],
+ ],
+ ['action_url' => 'https://ehkim.gnuboard.net/login'],
+ );
+
+ $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['button'][0]['url_mobile']);
+ }
+
+ public function test_원문_접두어가_있어도_변수값이_상대경로면_접두어를_유지한다(): void
+ {
+ // 변수값 자체가 프로토콜을 포함하지 않는 경우(상대경로 등) 접두어는 진짜로 필요한
+ // 부분이므로 제거하면 안 된다.
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'buttons' => [
+ ['name' => '이동', 'linkType' => 'WL', 'linkMo' => 'http://#{path}'],
+ ],
+ ],
+ ['path' => '/foo'],
+ );
+
+ $this->assertSame('http:///foo', $result['extra']['button'][0]['url_mobile']);
+ }
+
+ public function test_대표링크도_프로토콜_중복을_제거한다(): void
+ {
+ // mapLinkFields(대표링크)도 mapButtons 와 동일 규칙을 공유해야 한다.
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'templateRepresentLink' => ['linkMo' => 'http://#{action_url}'],
+ ],
+ ['action_url' => 'https://ehkim.gnuboard.net/login'],
+ );
+
+ $this->assertSame('https://ehkim.gnuboard.net/login', $result['extra']['link']['url_mobile']);
+ }
+
+ public function test_변수가_data에_없으면_원문을_유지한다(): void
+ {
+ // data 에 없는 변수는 원문(#{key}) 유지 정책 — 프로토콜 중복 방어 로직이 이 정책을
+ // 깨서는 안 된다(원문이 http:// 로 시작하지만 치환 자체가 안 일어나므로 그대로 둔다).
+ $result = $this->mapper()->map(
+ [
+ 'templateContent' => '본문',
+ 'buttons' => [
+ ['name' => '로그인하기', 'linkType' => 'WL', 'linkMo' => 'http://#{unknown_var}'],
+ ],
+ ],
+ [],
+ );
+
+ $this->assertSame('http://#{unknown_var}', $result['extra']['button'][0]['url_mobile']);
+ }
+
+ public function test_부재_필드는_extra에_넣지_않는다(): void
+ {
+ // 본문만 있는 단순 템플릿 → extra 는 비어야 한다.
+ $result = $this->mapper()->map(['templateContent' => '본문'], []);
+
+ $this->assertSame('본문', $result['message']);
+ $this->assertSame([], $result['extra']);
+ }
+
+ public function test_빈_버튼배열은_extra에_button을_만들지_않는다(): void
+ {
+ $result = $this->mapper()->map(
+ ['templateContent' => '본문', 'buttons' => []],
+ [],
+ );
+
+ $this->assertArrayNotHasKey('button', $result['extra']);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php
new file mode 100644
index 00000000..4c2093de
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkTemplateServiceTest.php
@@ -0,0 +1,173 @@
+ $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']);
+ // 회귀 방지: 배지 label_key 는 프론트 lang 키 형식(templates.status.*)이어야 한다.
+ // 백엔드 messages.php 네임스페이스(::messages.template.status.*)로 주면 프론트 $t() 가
+ // 해석하지 못해 라벨 원문이 목록/상세에 그대로 노출된다(PO 브라우저 검수로 발견된 회귀).
+ $this->assertSame(
+ 'sirsoft-message_bizppurio.templates.status.sendable',
+ $tpl['status_badge']['label_key'],
+ );
+ // 조회 전용 — 상태별 가능 액션(available_actions)은 더 이상 부가하지 않는다.
+ $this->assertArrayNotHasKey('available_actions', $tpl);
+ $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_발신프로필은_data_success_배열을_반환한다(): void
+ {
+ // 규격(5.발신프로필관리): /v3/kakao/profile/use 응답 data 는 {success:[...], fail:[...]}
+ // 2단 봉투다. 실제 발신프로필 목록은 data.success 안에 있으므로 그 배열을 반환해야 한다.
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response([
+ 'code' => '200',
+ 'data' => [
+ 'success' => [
+ ['senderKey' => 'SK_40', 'name' => '테스트채널', 'status' => 'A'],
+ ],
+ 'fail' => [],
+ ],
+ ], 200),
+ ]);
+
+ $profiles = $this->service()->senderProfiles();
+
+ // success 배열이 그대로 반환되어야 한다(껍데기 {success,fail} 가 아님).
+ $this->assertCount(1, $profiles);
+ $this->assertSame('SK_40', $profiles[0]['senderKey']);
+ $this->assertSame('테스트채널', $profiles[0]['name']);
+ // 회귀 방지: data 통째 반환 시 노출되던 success/fail 키가 없어야 한다.
+ $this->assertArrayNotHasKey('success', $profiles);
+ $this->assertArrayNotHasKey('fail', $profiles);
+ }
+
+ public function test_발신프로필_키_미설정시_예외(): void
+ {
+ $this->expectException(BizppurioApiException::class);
+
+ $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()->list();
+ $this->fail('예외가 발생해야 한다.');
+ } catch (BizppurioApiException $e) {
+ $this->assertSame('7204', $e->getResultCode());
+ $this->assertStringContainsString('템플릿 불일치', $e->getMessage());
+ }
+ }
+
+ public function test_목록조회는_kapi_508을_빈_목록으로_처리한다(): void
+ {
+ // 카카오 결과코드 508 = "요청한 데이터가 없음"(13.응답코드정의.md). 목록 검색에서
+ // 매칭 결과가 0건일 때 카카오가 이 코드로 응답하므로, 진짜 에러가 아니라 빈 목록으로
+ // 취급해야 한다(PO 실측: "댓글"은 200 정상 필터링, "대글"처럼 매칭 없는 키워드만 508).
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(['code' => '508', 'message' => '요청한 데이타가 없습니다.'], 200),
+ ]);
+
+ $result = $this->service()->list(['keyword' => '대글']);
+
+ $this->assertSame([], $result['templates']);
+ $this->assertSame(0, $result['pagination']['total']);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php
new file mode 100644
index 00000000..e36ba1f8
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioApiClientTest.php
@@ -0,0 +1,177 @@
+ 'acct',
+ 'type' => 'sms',
+ 'from' => '07012345678',
+ 'to' => '01011112222',
+ 'refkey' => 'ref1',
+ 'content' => ['sms' => ['message' => 'hi']],
+ ];
+
+ private function makeSettings(bool $isTestMode = true): PluginSettingsService
+ {
+ $mock = Mockery::mock(PluginSettingsService::class);
+ $mock->shouldReceive('get')
+ ->with(self::IDENTIFIER, 'is_test_mode', true)
+ ->andReturn($isTestMode);
+
+ return $mock;
+ }
+
+ /**
+ * getToken/refreshToken 을 순차 반환하는 TokenService mock.
+ *
+ * @param string $token getToken 반환 토큰
+ * @param string|null $refreshed refreshToken 반환 토큰(null 이면 미기대)
+ */
+ private function makeToken(string $token, ?string $refreshed = null): BizppurioTokenService
+ {
+ $mock = Mockery::mock(BizppurioTokenService::class);
+ $mock->shouldReceive('getToken')->andReturn($token);
+
+ if ($refreshed !== null) {
+ $mock->shouldReceive('refreshToken')->once()->andReturn($refreshed);
+ }
+
+ return $mock;
+ }
+
+ public function test_발송_성공(): void
+ {
+ Http::fake([
+ '*/v3/message' => Http::response([
+ 'code' => 1000, 'description' => 'Success', 'refkey' => 'ref1', 'messagekey' => 'mk1',
+ ], 200),
+ ]);
+
+ $client = new BizppurioApiClient($this->makeToken('TOKEN'), $this->makeSettings());
+ $result = $client->sendMessage($this->payload);
+
+ $this->assertTrue($client->isSuccess($result));
+ $this->assertSame('mk1', $result['messagekey']);
+ Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer TOKEN'));
+ }
+
+ public function test_토큰무효_3002는_재발급_후_1회_재시도(): void
+ {
+ Http::fake([
+ '*/v3/message' => Http::sequence()
+ ->push(['code' => 3002, 'description' => 'Invalid token'], 200)
+ ->push(['code' => 1000, 'description' => 'Success', 'messagekey' => 'mk2'], 200),
+ ]);
+
+ $client = new BizppurioApiClient(
+ $this->makeToken('OLD', 'NEW'),
+ $this->makeSettings(),
+ );
+ $result = $client->sendMessage($this->payload);
+
+ $this->assertTrue($client->isSuccess($result));
+ Http::assertSentCount(2);
+ }
+
+ public function test_운영_모드는_live_도메인_호출(): void
+ {
+ Http::fake([
+ 'api.bizppurio.com/*' => Http::response(['code' => 1000], 200),
+ ]);
+
+ $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings(false));
+ $client->sendMessage($this->payload);
+
+ Http::assertSent(fn ($request) => str_starts_with($request->url(), 'https://api.bizppurio.com'));
+ }
+
+ public function test_http_실패시_예외(): void
+ {
+ Http::fake(['*/v3/message' => Http::response([], 500)]);
+
+ $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings());
+
+ $this->expectException(BizppurioApiException::class);
+ $client->sendMessage($this->payload);
+ }
+
+ /**
+ * HTTP 실패 응답에 code·description 이 있으면 예외에 사유·결과코드로 담긴다.
+ *
+ * 회귀 배경: 실패 시 "메시지 발송 요청에 실패했습니다"만 뜨고 비즈뿌리오가 준 실제 사유·코드를
+ * 버려, 발송 이력·로그로 원인을 알 수 없던 문제. 실패 body 를 추출해 예외에 실어 이력에 남긴다.
+ */
+ public function test_http_실패시_응답_body_의_사유와_코드가_예외에_담긴다(): void
+ {
+ Http::fake([
+ '*/v3/message' => Http::response(
+ ['code' => 7103, 'description' => '발신 프로필 키가 유효하지 않음'],
+ 400,
+ ),
+ ]);
+
+ $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings());
+
+ try {
+ $client->sendMessage($this->payload);
+ $this->fail('예외가 발생해야 한다.');
+ } catch (BizppurioApiException $e) {
+ $this->assertSame('7103', $e->getResultCode(), '실패 body 의 code 가 예외에 담겨야 한다.');
+ $this->assertSame('발신 프로필 키가 유효하지 않음', $e->getMessage(), '실패 body 의 description 이 예외 메시지로 담겨야 한다.');
+ }
+ }
+
+ /**
+ * HTTP 실패 응답에 body 가 없으면 기본 사유로 폴백하고 결과코드는 null.
+ */
+ public function test_http_실패시_body_없으면_기본_사유_폴백(): void
+ {
+ Http::fake(['*/v3/message' => Http::response([], 500)]);
+
+ $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings());
+
+ try {
+ $client->sendMessage($this->payload);
+ $this->fail('예외가 발생해야 한다.');
+ } catch (BizppurioApiException $e) {
+ $this->assertNull($e->getResultCode());
+ $this->assertNotSame('', $e->getMessage());
+ }
+ }
+
+ public function test_영구실패_결과코드는_그대로_반환(): void
+ {
+ Http::fake([
+ '*/v3/message' => Http::response(['code' => 3006, 'description' => 'Account error'], 200),
+ ]);
+
+ $client = new BizppurioApiClient($this->makeToken('T'), $this->makeSettings());
+ $result = $client->sendMessage($this->payload);
+
+ $this->assertFalse($client->isSuccess($result));
+ $this->assertSame(3006, $result['code']);
+ // 3006 은 재시도 대상 아님 → HTTP 1회
+ Http::assertSentCount(1);
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php
new file mode 100644
index 00000000..6579b1ab
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioKakaoApiClientTest.php
@@ -0,0 +1,151 @@
+ $settings
+ */
+ private function makeSettings(array $settings): PluginSettingsService
+ {
+ $mock = Mockery::mock(PluginSettingsService::class);
+ $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings);
+
+ return $mock;
+ }
+
+ private function client(): BizppurioKakaoApiClient
+ {
+ return new BizppurioKakaoApiClient(
+ $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => 'key01']),
+ );
+ }
+
+ public function test_발신프로필_조회는_bizid_apikey를_body에_싣는다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['success' => []]], 200),
+ ]);
+
+ $result = $this->client()->getSenderProfiles();
+
+ $this->assertTrue($this->client()->isSuccess($result));
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), 'kapi.ppurio.com/v3/kakao/profile/use')
+ && $request['bizId'] === 'biz01'
+ && $request['apiKey'] === 'key01';
+ });
+ }
+
+ public function test_템플릿_목록은_senderkey를_포함한다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => []], 200),
+ ]);
+
+ $this->client()->getTemplateList('SENDER_KEY', ['count' => 20]);
+
+ Http::assertSent(function ($request) {
+ return str_contains($request->url(), '/v3/kakao/template/list')
+ && $request['senderKey'] === 'SENDER_KEY'
+ && $request['count'] === 20
+ && $request['bizId'] === 'biz01';
+ });
+ }
+
+ public function test_템플릿_상세_조회(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(['code' => '200', 'data' => ['templateCode' => 'TW_1']], 200),
+ ]);
+
+ $result = $this->client()->getTemplateDetail('SK', 'TW_1');
+
+ $this->assertSame('TW_1', $result['data']['templateCode']);
+ }
+
+ public function test_임의_엔드포인트_request_위임(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(['code' => '200'], 200),
+ ]);
+
+ $this->client()->request('/v3/kakao/template/add', ['templateName' => 'T']);
+
+ Http::assertSent(fn ($request) => str_contains($request->url(), '/v3/kakao/template/add')
+ && $request['templateName'] === 'T');
+ }
+
+ public function test_자격증명_미설정시_예외(): void
+ {
+ $client = new BizppurioKakaoApiClient(
+ $this->makeSettings(['bizppurio_id' => 'biz01', 'api_key' => '']),
+ );
+
+ $this->expectException(BizppurioApiException::class);
+ $client->getSenderProfiles();
+ }
+
+ public function test_http_실패시_예외(): void
+ {
+ Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]);
+
+ $this->expectException(BizppurioApiException::class);
+ $this->client()->getSenderProfiles();
+ }
+
+ public function test_http_실패시_카카오_message와_code를_예외에_싣는다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(
+ ['code' => '403', 'message' => '접근할 수 없는 IP 입니다. (114.207.113.206)'],
+ 403,
+ ),
+ ]);
+
+ try {
+ $this->client()->getSenderProfiles();
+ $this->fail('BizppurioApiException 이 발생해야 한다.');
+ } catch (BizppurioApiException $e) {
+ $this->assertSame('접근할 수 없는 IP 입니다. (114.207.113.206)', $e->getMessage());
+ $this->assertSame('403', $e->getResultCode());
+ $this->assertSame(403, $e->getHttpStatus());
+ }
+ }
+
+ public function test_http_실패_body에_message가_없으면_일반문구로_폴백한다(): void
+ {
+ Http::fake(['kapi.ppurio.com/*' => Http::response([], 500)]);
+
+ try {
+ $this->client()->getSenderProfiles();
+ $this->fail('BizppurioApiException 이 발생해야 한다.');
+ } catch (BizppurioApiException $e) {
+ $this->assertSame(
+ __('sirsoft-message_bizppurio::messages.error.kakao_request_failed'),
+ $e->getMessage(),
+ );
+ $this->assertNull($e->getResultCode());
+ $this->assertSame(500, $e->getHttpStatus());
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php
new file mode 100644
index 00000000..6d453d83
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php
@@ -0,0 +1,253 @@
+ $store 참조로 전달되는 캐시 저장소
+ */
+ private function makeCache(array &$store): CacheInterface
+ {
+ $mock = Mockery::mock(CacheInterface::class);
+
+ $mock->shouldReceive('remember')->andReturnUsing(
+ function (string $key, callable $callback) use (&$store) {
+ if (! array_key_exists($key, $store)) {
+ $store[$key] = $callback();
+ }
+
+ return $store[$key];
+ }
+ );
+
+ $mock->shouldReceive('forget')->andReturnUsing(
+ function (string $key) use (&$store) {
+ unset($store[$key]);
+
+ return true;
+ }
+ );
+
+ $mock->shouldReceive('put')->andReturnUsing(
+ function (string $key, $value) use (&$store) {
+ $store[$key] = $value;
+
+ return true;
+ }
+ );
+
+ return $mock;
+ }
+
+ /**
+ * bizppurio_id/password/is_test_mode 를 반환하는 설정 mock.
+ *
+ * @param array $settings
+ */
+ private function makeSettings(array $settings): PluginSettingsService
+ {
+ $mock = Mockery::mock(PluginSettingsService::class);
+ $mock->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings);
+
+ return $mock;
+ }
+
+ public function test_토큰_발급_후_캐시에서_재사용된다(): void
+ {
+ Http::fake([
+ '*/v1/token' => Http::response(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200),
+ ]);
+
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ $this->assertSame('TOKEN_A', $service->getToken());
+ // 두 번째 호출은 캐시에서 → HTTP 1회만
+ $this->assertSame('TOKEN_A', $service->getToken());
+ Http::assertSentCount(1);
+ }
+
+ public function test_refresh는_forget_후_재발급한다(): void
+ {
+ $tokens = ['TOKEN_A', 'TOKEN_B'];
+ Http::fake([
+ '*/v1/token' => Http::sequence()
+ ->push(['accesstoken' => 'TOKEN_A', 'type' => 'Bearer'], 200)
+ ->push(['accesstoken' => 'TOKEN_B', 'type' => 'Bearer'], 200),
+ ]);
+
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ $this->assertSame('TOKEN_A', $service->getToken());
+ // refresh → 캐시 무효화 후 새 토큰
+ $this->assertSame('TOKEN_B', $service->refreshToken());
+ Http::assertSentCount(2);
+ }
+
+ public function test_검수_모드는_dev_도메인을_호출한다(): void
+ {
+ Http::fake([
+ 'dev-api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200),
+ ]);
+
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ $service->getToken();
+ Http::assertSent(fn ($request) => str_contains($request->url(), 'dev-api.bizppurio.com'));
+ }
+
+ public function test_운영_모드는_live_도메인을_호출한다(): void
+ {
+ Http::fake([
+ 'api.bizppurio.com/*' => Http::response(['accesstoken' => 'T', 'type' => 'Bearer'], 200),
+ ]);
+
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => false]),
+ );
+
+ $service->getToken();
+ Http::assertSent(fn ($request) => str_contains($request->url(), 'api.bizppurio.com')
+ && ! str_contains($request->url(), 'dev-api.bizppurio.com'));
+ }
+
+ public function test_자격증명_미설정시_예외(): void
+ {
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => '', 'password' => '']),
+ );
+
+ $this->expectException(BizppurioApiException::class);
+ $service->getToken();
+ }
+
+ public function test_http_실패시_예외(): void
+ {
+ Http::fake(['*/v1/token' => Http::response([], 500)]);
+
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ $this->expectException(BizppurioApiException::class);
+ $service->getToken();
+ }
+
+ public function test_verifyCredentials는_캐시를_거치지_않고_재발급한다(): void
+ {
+ Http::fake([
+ '*/v1/token' => Http::response(['accesstoken' => 'FRESH_TOKEN', 'type' => 'Bearer'], 200),
+ ]);
+
+ $store = ['bizppurio:token' => 'STALE_TOKEN'];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ $this->assertSame('FRESH_TOKEN', $service->verifyCredentials());
+ Http::assertSentCount(1);
+ $this->assertSame('FRESH_TOKEN', $store['bizppurio:token']);
+ }
+
+ public function test_verifyCredentials_실패시_예외를_전파하고_캐시를_건드리지_않는다(): void
+ {
+ Http::fake(['*/v1/token' => Http::response([], 500)]);
+
+ $store = ['bizppurio:token' => 'EXISTING_TOKEN'];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ try {
+ $service->verifyCredentials();
+ $this->fail('예외가 발생해야 합니다.');
+ } catch (BizppurioApiException $e) {
+ // 실패 시 캐시는 그대로 보존 — 발송이 기존 토큰(만료 전까지)을 계속 사용
+ $this->assertSame('EXISTING_TOKEN', $store['bizppurio:token']);
+ }
+ }
+
+ public function test_토큰_발급_실패시_비즈뿌리오_사유가_메시지에_포함된다(): void
+ {
+ Http::fake([
+ '*/v1/token' => Http::response(['code' => '3007', 'description' => 'invalid password'], 401),
+ ]);
+
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ try {
+ $service->getToken();
+ $this->fail('예외가 발생해야 합니다.');
+ } catch (BizppurioApiException $e) {
+ $this->assertStringContainsString('invalid password', $e->getMessage());
+ $this->assertSame('3007', $e->getResultCode());
+ }
+ }
+
+ public function test_토큰_발급_실패시_사유_없으면_고정_메시지로_폴백한다(): void
+ {
+ Http::fake(['*/v1/token' => Http::response([], 500)]);
+
+ $store = [];
+ $service = new BizppurioTokenService(
+ $this->makeCache($store),
+ $this->makeSettings(['bizppurio_id' => 'acct', 'password' => 'pw', 'is_test_mode' => true]),
+ );
+
+ try {
+ $service->getToken();
+ $this->fail('예외가 발생해야 합니다.');
+ } catch (BizppurioApiException $e) {
+ $this->assertSame(
+ __('sirsoft-message_bizppurio::messages.error.token_issue_failed'),
+ $e->getMessage()
+ );
+ $this->assertNull($e->getResultCode());
+ }
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php
new file mode 100644
index 00000000..c6608454
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php
@@ -0,0 +1,145 @@
+service = new DispatchResultService(
+ new BizppurioDispatchRepository,
+ new ResultCodeResolver,
+ );
+ }
+
+ /**
+ * 코어 로그 id 에 연결된 dispatch 1건을 만듭니다.
+ *
+ * @param array $overrides dispatch 오버라이드
+ */
+ private function linkedDispatch(int $logId, array $overrides = []): BizppurioDispatch
+ {
+ return BizppurioDispatch::create(array_merge([
+ 'refkey' => 'r'.uniqid(),
+ 'channel' => 'sms',
+ 'to_number' => '01011112222',
+ 'content' => 'x',
+ 'notification_type' => 'welcome',
+ 'notification_log_id' => $logId,
+ 'status' => 'success',
+ 'result_code' => '4100',
+ 'source' => 'auto',
+ 'sent_at' => now(),
+ ], $overrides));
+ }
+
+ public function test_성공_결과를_로그id_키_맵으로_반환한다(): void
+ {
+ $this->linkedDispatch(10);
+
+ $results = $this->service->resultsForLogIds([10]);
+
+ $this->assertArrayHasKey(10, $results);
+ $this->assertSame('success', $results[10]['status']);
+ $this->assertSame('4100', $results[10]['result_code']);
+ $this->assertFalse($results[10]['is_low_balance']);
+ // 민감정보(전화번호)는 결과에 포함하지 않는다.
+ $this->assertArrayNotHasKey('to_number', $results[10]);
+ }
+
+ public function test_잔액부족_코드는_is_low_balance_true다(): void
+ {
+ $this->linkedDispatch(20, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']);
+
+ $results = $this->service->resultsForLogIds([20]);
+
+ $this->assertTrue($results[20]['is_low_balance']);
+ }
+
+ public function test_대체발송_상태가_결과에_포함된다(): void
+ {
+ $this->linkedDispatch(30, ['fallback_status' => '성공']);
+
+ $results = $this->service->resultsForLogIds([30]);
+
+ $this->assertSame('성공', $results[30]['fallback_status']);
+ }
+
+ public function test_result_code_없으면_result_label_null이다(): void
+ {
+ $this->linkedDispatch(40, ['status' => 'sent', 'result_code' => null]);
+
+ $results = $this->service->resultsForLogIds([40]);
+
+ $this->assertNull($results[40]['result_label']);
+ $this->assertNotNull($results[40]['status_label']); // 상태 라벨은 있음(발송중)
+ }
+
+ public function test_검수_모드_스냅샷이_결과에_포함된다(): void
+ {
+ $this->linkedDispatch(70, ['is_test_mode' => true]);
+
+ $results = $this->service->resultsForLogIds([70]);
+
+ $this->assertTrue($results[70]['is_test_mode']);
+ }
+
+ public function test_검수_모드_스냅샷이_없는_과거_이력은_null이다(): void
+ {
+ $this->linkedDispatch(71, ['is_test_mode' => null]);
+
+ $results = $this->service->resultsForLogIds([71]);
+
+ $this->assertNull($results[71]['is_test_mode']);
+ }
+
+ public function test_매칭되지_않는_로그id는_맵에서_빠진다(): void
+ {
+ $this->linkedDispatch(50);
+
+ $results = $this->service->resultsForLogIds([50, 999]);
+
+ $this->assertArrayHasKey(50, $results);
+ $this->assertArrayNotHasKey(999, $results);
+ }
+
+ public function test_recent_results_는_연결된_최근_결과를_로그id_키_맵으로_반환한다(): void
+ {
+ $this->linkedDispatch(60, ['result_code' => '4100']);
+ $this->linkedDispatch(61, ['channel' => 'alimtalk', 'status' => 'failed', 'result_code' => '7436']);
+
+ $results = $this->service->recentResults();
+
+ $this->assertSame('success', $results[60]['status']);
+ $this->assertTrue($results[61]['is_low_balance']);
+ }
+
+ /**
+ * 실제 발송 본문(dispatch.content)이 결과에 포함된다 — 알림톡은 코어
+ * notification_logs.body(대체발송용 코어 템플릿 값)와 실제 카카오 발송 내용이 달라,
+ * 화면이 채널별로 구분해 실제 발송 내용을 보여줄 수 있도록 결과에 담아야 한다.
+ */
+ public function test_실제_발송_본문이_결과에_포함된다(): void
+ {
+ $this->linkedDispatch(80, ['content' => '[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.']);
+
+ $results = $this->service->resultsForLogIds([80]);
+
+ $this->assertSame('[그누보드7] 회원가입을 환영합니다\n\n김으네님, 가입이 완료되었습니다.', $results[80]['content']);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php
new file mode 100644
index 00000000..c84736e3
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php
@@ -0,0 +1,174 @@
+flush();
+ }
+
+ /**
+ * @param array $settings
+ */
+ private function resolver(array $settings = ['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40']): KakaoTemplateContentResolver
+ {
+ $pluginSettings = Mockery::mock(PluginSettingsService::class);
+ $pluginSettings->shouldReceive('get')->with(self::IDENTIFIER)->andReturn($settings);
+ // template_cache_minutes 조회 (기본 60분 → 내부에서 ×60 초)
+ $pluginSettings->shouldReceive('get')
+ ->with(self::IDENTIFIER, 'template_cache_minutes', Mockery::any())
+ ->andReturnUsing(fn ($id, $key, $default) => $settings['template_cache_minutes'] ?? $default);
+
+ $kakao = new BizppurioKakaoApiClient($pluginSettings);
+ $templates = new AlimtalkTemplateService($kakao, $pluginSettings);
+
+ // 실제 확장 캐시 드라이버 주입 (contextual binding 과 동일 계약)
+ $cache = app(CacheInterface::class);
+
+ return new KakaoTemplateContentResolver($templates, $cache, $pluginSettings);
+ }
+
+ /**
+ * 상세조회 응답의 원본 카카오 필드(templateContent/buttons)를 그대로 반환한다.
+ */
+ public function test_템플릿_내용을_조회해_반환한다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response([
+ 'code' => '200',
+ 'data' => [
+ 'templateCode' => 'TW_1',
+ 'templateContent' => '#{name}님 주문 완료',
+ 'buttons' => [
+ ['name' => '주문조회', 'linkType' => 'WL', 'linkMo' => 'https://m.shop/#{order}'],
+ ],
+ 'inspectionStatus' => 'APR',
+ 'status' => 'A',
+ ],
+ ], 200),
+ ]);
+
+ $content = $this->resolver()->resolve('TW_1');
+
+ $this->assertNotNull($content);
+ $this->assertSame('#{name}님 주문 완료', $content['templateContent']);
+ $this->assertSame('WL', $content['buttons'][0]['linkType']);
+ }
+
+ /**
+ * 같은 template_code 를 두 번 조회하면 두 번째는 캐시 히트 — kapi 호출은 1회뿐.
+ */
+ public function test_두번째_조회는_캐시_히트로_kapi를_다시_부르지_않는다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response([
+ 'code' => '200',
+ 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
+ ], 200),
+ ]);
+
+ $resolver = $this->resolver();
+ $resolver->resolve('TW_1');
+ $resolver->resolve('TW_1');
+
+ Http::assertSentCount(1);
+ }
+
+ /**
+ * TTL=0 이면 캐시를 우회 — 매 조회마다 kapi 를 부른다(항상 최신).
+ */
+ public function test_ttl이_0이면_캐시를_우회해_매번_조회한다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response([
+ 'code' => '200',
+ 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
+ ], 200),
+ ]);
+
+ $resolver = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0]);
+ $resolver->resolve('TW_1');
+ $resolver->resolve('TW_1');
+
+ Http::assertSentCount(2);
+ }
+
+ /**
+ * 조회 실패(고아 템플릿·장애)는 null 을 반환한다(호출측이 발송 skip).
+ */
+ public function test_조회_실패시_null을_반환한다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(['code' => '7315', 'message' => '템플릿 없음'], 200),
+ ]);
+
+ $this->assertNull($this->resolver()->resolve('GONE'));
+ }
+
+ /**
+ * rate limit(HTTP 429)이면 짧게 재시도한 뒤 성공하면 내용을 반환한다(조회 폭주 완화).
+ */
+ public function test_rate_limit_429는_재시도해_성공하면_반환한다(): void
+ {
+ // 첫 응답 429 → 재시도 → 두 번째 성공.
+ Http::fakeSequence('kapi.ppurio.com/*')
+ ->push(['code' => '5002', 'description' => 'too many requests'], 429)
+ ->push([
+ 'code' => '200',
+ 'data' => ['templateCode' => 'TW_1', 'templateContent' => '본문', 'inspectionStatus' => 'APR', 'status' => 'A'],
+ ], 200);
+
+ $content = $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0])
+ ->resolve('TW_1');
+
+ $this->assertNotNull($content);
+ $this->assertSame('본문', $content['templateContent']);
+ Http::assertSentCount(2);
+ }
+
+ /**
+ * 재시도해도 계속 429면 null 을 반환한다(무한 재시도 방지).
+ */
+ public function test_rate_limit이_지속되면_null을_반환한다(): void
+ {
+ Http::fake([
+ 'kapi.ppurio.com/*' => Http::response(['code' => '5002', 'description' => 'too many requests'], 429),
+ ]);
+
+ $this->assertNull(
+ $this->resolver(['bizppurio_id' => 'biz01', 'api_key' => 'key01', 'sender_key' => 'SK_40', 'template_cache_minutes' => 0])
+ ->resolve('TW_1'),
+ );
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php
new file mode 100644
index 00000000..af70caf9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/MessagePayloadBuilderTest.php
@@ -0,0 +1,172 @@
+ $settings
+ */
+ private function makeBuilder(array $settings): MessagePayloadBuilder
+ {
+ $mock = Mockery::mock(PluginSettingsService::class);
+ $mock->shouldReceive('get')
+ ->with(self::IDENTIFIER, Mockery::type('string'), Mockery::any())
+ ->andReturnUsing(fn ($id, $key, $default) => $settings[$key] ?? $default);
+
+ return new MessagePayloadBuilder($mock);
+ }
+
+ public function test_sms_payload_구조(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ ]);
+
+ $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123');
+
+ $this->assertSame('acct01', $payload['account']);
+ $this->assertSame('sms', $payload['type']);
+ $this->assertSame('07012345678', $payload['from']);
+ $this->assertSame('01011112222', $payload['to']);
+ $this->assertSame('ref123', $payload['refkey']);
+ $this->assertSame('테스트 본문', $payload['content']['sms']['message']);
+ }
+
+ public function test_lms_payload는_subject를_포함한다(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ ]);
+
+ $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목');
+
+ $this->assertSame('lms', $payload['type']);
+ $this->assertSame('제목', $payload['content']['lms']['subject']);
+ $this->assertSame('긴 본문', $payload['content']['lms']['message']);
+ }
+
+ public function test_lms_subject_null이면_생략된다(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ ]);
+
+ $payload = $builder->buildLms('01011112222', '본문', 'ref789', null);
+
+ $this->assertArrayNotHasKey('subject', $payload['content']['lms']);
+ }
+
+ public function test_알림톡_payload는_senderkey와_templatecode를_포함한다(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ 'sender_key' => 'senderkey40',
+ ]);
+
+ $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [
+ 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']],
+ ]);
+
+ $this->assertSame('at', $payload['type']);
+ $at = $payload['content']['at'];
+ $this->assertSame('senderkey40', $at['senderkey']);
+ $this->assertSame('TW_1234', $at['templatecode']);
+ $this->assertSame('알림톡 본문', $at['message']);
+ // extra(버튼) 병합 확인
+ $this->assertSame('확인', $at['button'][0]['name']);
+ }
+
+ public function test_forHistory는_sms_payload에서_to_refkey_type_message를_제외한다(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ ]);
+
+ $payload = $builder->buildSms('01011112222', '테스트 본문', 'ref123');
+ $history = $builder->forHistory($payload);
+
+ $this->assertArrayNotHasKey('to', $history);
+ $this->assertArrayNotHasKey('refkey', $history);
+ $this->assertArrayNotHasKey('type', $history);
+ $this->assertArrayNotHasKey('message', $history['content']['sms']);
+ // 다른 컬럼에 없는 값은 유지돼야 한다.
+ $this->assertSame('acct01', $history['account']);
+ $this->assertSame('07012345678', $history['from']);
+ }
+
+ public function test_forHistory는_lms_payload에서_subject는_유지하고_message만_제외한다(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ ]);
+
+ $payload = $builder->buildLms('01011112222', '긴 본문', 'ref456', '제목');
+ $history = $builder->forHistory($payload);
+
+ $this->assertArrayNotHasKey('message', $history['content']['lms']);
+ $this->assertSame('제목', $history['content']['lms']['subject'], 'subject 는 다른 컬럼에 없으므로 유지돼야 한다.');
+ }
+
+ public function test_forHistory는_알림톡_payload에서_message만_제외하고_버튼_등_요소는_유지한다(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ 'sender_key' => 'senderkey40',
+ ]);
+
+ $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999', [
+ 'button' => [['name' => '확인', 'type' => 'WL', 'url_mobile' => 'https://x']],
+ ]);
+ $history = $builder->forHistory($payload);
+
+ $at = $history['content']['at'];
+ $this->assertArrayNotHasKey('message', $at, '치환된 본문은 content 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertSame('senderkey40', $at['senderkey'], 'senderkey 는 다른 컬럼에 없으므로 유지돼야 한다.');
+ $this->assertSame('TW_1234', $at['templatecode'], 'templatecode 는 다른 컬럼에 없으므로 유지돼야 한다.');
+ $this->assertSame('확인', $at['button'][0]['name'], '버튼(요소)은 결함② URL 검증에 필요하므로 유지돼야 한다.');
+ }
+
+ public function test_forHistory는_대체발송_recontent_message도_제외한다(): void
+ {
+ $builder = $this->makeBuilder([
+ 'bizppurio_id' => 'acct01',
+ 'sender_number' => '07012345678',
+ 'sender_key' => 'senderkey40',
+ ]);
+
+ $payload = $builder->buildAlimtalk('01011112222', 'TW_1234', '알림톡 본문', 'ref999');
+ $payload['resend'] = ['first' => 'sms'];
+ $payload['recontent'] = ['sms' => ['message' => '대체 SMS 본문']];
+
+ $history = $builder->forHistory($payload);
+
+ $this->assertArrayNotHasKey('message', $history['recontent']['sms'], '대체발송 본문도 개인식별 텍스트이므로 제외돼야 한다.');
+ $this->assertSame(['first' => 'sms'], $history['resend'], 'resend 구조 자체는 다른 컬럼에 없으므로 유지돼야 한다.');
+ }
+
+ protected function tearDown(): void
+ {
+ Mockery::close();
+ parent::tearDown();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php
new file mode 100644
index 00000000..b1f71f6b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php
@@ -0,0 +1,316 @@
+> $templates list()['templates'] 반환값
+ */
+ private function fakeTemplateService(array $templates): AlimtalkTemplateService
+ {
+ $service = Mockery::mock(AlimtalkTemplateService::class);
+ $service->shouldReceive('list')->andReturn(['templates' => $templates, 'pagination' => []]);
+
+ return $service;
+ }
+
+ private function makeService(array $templates = [], ?KakaoTemplateContentResolver $kakaoContent = null): NotificationBindingService
+ {
+ return new NotificationBindingService(
+ new BizppurioNotificationBindingRepository,
+ $this->fakeTemplateService($templates),
+ $kakaoContent ?? $this->fakeKakaoContent(),
+ );
+ }
+
+ /**
+ * clearMany 호출 인자를 기록하는 KakaoTemplateContentResolver mock 을 만듭니다.
+ */
+ private function fakeKakaoContent(): KakaoTemplateContentResolver
+ {
+ $resolver = Mockery::mock(KakaoTemplateContentResolver::class);
+ $resolver->shouldReceive('clearMany')
+ ->andReturnUsing(fn (array $codes) => count(array_unique(array_filter($codes))));
+
+ return $resolver;
+ }
+
+ public function test_all은_알림톡_연동을_type_키_맵으로_반환한다(): void
+ {
+ (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
+ 'template_code' => 'TW_1234',
+ 'template_name' => '주문완료',
+ 'fallback_sms_enabled' => true,
+ 'is_active' => true,
+ ]);
+
+ $map = $this->makeService()->all();
+
+ $this->assertArrayHasKey('order_confirmed', $map);
+ $this->assertSame('TW_1234', $map['order_confirmed']['template_code']);
+ $this->assertSame('주문완료', $map['order_confirmed']['template_name']);
+ $this->assertTrue($map['order_confirmed']['fallback_sms_enabled']);
+ }
+
+ public function test_all은_연동이_없으면_빈_맵을_반환한다(): void
+ {
+ $this->assertSame([], $this->makeService()->all());
+ }
+
+ public function test_all_withAvailability는_승인목록에_있는_연동을_사용가능으로_표시한다(): void
+ {
+ (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
+ 'template_code' => 'TW_LIVE',
+ 'template_name' => '살아있음',
+ 'is_active' => true,
+ ]);
+
+ // 승인 목록에 TW_LIVE 가 있음 → 사용 가능(is_unavailable=false)
+ $map = $this->makeService([
+ ['templateCode' => 'TW_LIVE', 'templateName' => '살아있음', 'serviceStatus' => 'ACT'],
+ ])->all(withAvailability: true);
+
+ $this->assertFalse($map['order_confirmed']['is_unavailable']);
+ }
+
+ public function test_all_withAvailability는_승인목록에_없는_연동을_사용불가로_표시한다(): void
+ {
+ (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
+ 'template_code' => 'TW_GONE',
+ 'template_name' => '삭제됨',
+ 'is_active' => true,
+ ]);
+
+ // 승인 목록에 TW_GONE 이 없음(삭제·차단·미승인) → 사용 불가(is_unavailable=true)
+ $map = $this->makeService([
+ ['templateCode' => 'TW_OTHER', 'templateName' => '다른템플릿', 'serviceStatus' => 'ACT'],
+ ])->all(withAvailability: true);
+
+ $this->assertTrue($map['order_confirmed']['is_unavailable']);
+ }
+
+ public function test_all_withAvailability는_카카오_조회_실패시_소실판정을_생략한다(): void
+ {
+ (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
+ 'template_code' => 'TW_1234',
+ 'template_name' => '주문완료',
+ 'is_active' => true,
+ ]);
+
+ // 카카오 승인 목록 조회가 실패(자격증명 미설정·장애)하면 판정을 건너뛴다 —
+ // 살아있는 연동이 일시 장애로 "사용 불가"로 오탐되지 않게(is_unavailable 필드 미부여).
+ $throwingTemplates = Mockery::mock(AlimtalkTemplateService::class);
+ $throwingTemplates->shouldReceive('list')
+ ->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정'));
+
+ $service = new NotificationBindingService(
+ new BizppurioNotificationBindingRepository,
+ $throwingTemplates,
+ $this->fakeKakaoContent(),
+ );
+
+ $map = $service->all(withAvailability: true);
+
+ $this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed'], '조회 실패 시 소실 판정을 생략해야 한다.');
+ }
+
+ public function test_all은_기본값에서_소실판정을_하지_않는다(): void
+ {
+ (new BizppurioNotificationBindingRepository)->upsert('order_confirmed', 'alimtalk', [
+ 'template_code' => 'TW_1234',
+ 'template_name' => '주문완료',
+ 'is_active' => true,
+ ]);
+
+ // store(저장) 경로처럼 프리필만 필요하면 카카오 대조를 생략한다(is_unavailable 필드 없음).
+ $map = $this->makeService()->all();
+
+ $this->assertArrayNotHasKey('is_unavailable', $map['order_confirmed']);
+ }
+
+ public function test_승인_상태_템플릿만_연결_후보로_노출한다(): void
+ {
+ $service = $this->makeService([
+ ['templateCode' => 'TW_A', 'templateName' => '승인됨', 'serviceStatus' => 'ACT'],
+ ['templateCode' => 'TW_B', 'templateName' => '발송전', 'serviceStatus' => 'RDY'],
+ ['templateCode' => 'TW_C', 'templateName' => '검수중', 'serviceStatus' => 'REQ'],
+ ['templateCode' => 'TW_D', 'templateName' => '반려', 'serviceStatus' => 'REJ'],
+ ]);
+
+ $codes = array_column($service->approvedTemplates(), 'template_code');
+
+ $this->assertContains('TW_A', $codes, 'ACT(정상)는 노출되어야 한다.');
+ $this->assertContains('TW_B', $codes, 'RDY(발송전)는 노출되어야 한다.');
+ $this->assertNotContains('TW_C', $codes, 'REQ(검수중)는 제외되어야 한다.');
+ $this->assertNotContains('TW_D', $codes, 'REJ(반려)는 제외되어야 한다.');
+ }
+
+ /**
+ * bind() 승인 검증을 통과시키는 템플릿 목록으로 서비스를 만듭니다 (bind 계열 테스트 공용 fixture).
+ *
+ * @param array $approvedCodes 승인(발송 가능) 처리할 template_code 목록
+ */
+ private function makeServiceWithApprovedCodes(array $approvedCodes): NotificationBindingService
+ {
+ return $this->makeService(array_map(
+ fn (string $code) => ['templateCode' => $code, 'templateName' => $code, 'serviceStatus' => 'ACT'],
+ $approvedCodes,
+ ));
+ }
+
+ public function test_bind는_연동을_생성하고_unbind는_삭제한다(): void
+ {
+ $service = $this->makeServiceWithApprovedCodes(['TW_1236']);
+
+ $service->bind('welcome', [
+ 'template_code' => 'TW_1236',
+ 'template_name' => '가입환영',
+ 'fallback_sms_enabled' => false,
+ ]);
+
+ $this->assertDatabaseHas('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ 'template_code' => 'TW_1236',
+ 'is_active' => true,
+ ]);
+
+ $service->unbind('welcome');
+
+ $this->assertDatabaseMissing('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ ]);
+ }
+
+ public function test_bind는_같은_알림에_대해_갱신한다(): void
+ {
+ $service = $this->makeServiceWithApprovedCodes(['TW_1', 'TW_2']);
+
+ $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '첫번째']);
+ $service->bind('welcome', ['template_code' => 'TW_2', 'template_name' => '두번째']);
+
+ $this->assertDatabaseCount('bizppurio_notification_bindings', 1);
+ $this->assertDatabaseHas('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'template_code' => 'TW_2',
+ ]);
+ }
+
+ public function test_bind는_미승인_템플릿이면_예외를_던지고_저장하지_않는다(): void
+ {
+ // 회귀: bind() 가 승인 상태를 재검증하지 않아, 드롭다운(화면) 필터를 우회해 API 를
+ // 직접 호출하면 미승인 템플릿도 저장되던 결함.
+ $service = $this->makeServiceWithApprovedCodes(['TW_OTHER']);
+
+ $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class);
+
+ try {
+ $service->bind('welcome', ['template_code' => 'TW_UNAPPROVED', 'template_name' => '미승인']);
+ } finally {
+ $this->assertDatabaseMissing('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'template_code' => 'TW_UNAPPROVED',
+ ]);
+ }
+ }
+
+ public function test_bind는_카카오_조회_실패시_예외를_던지고_저장하지_않는다(): void
+ {
+ // 승인 여부를 판정할 수 없으면 안전측으로 저장을 거부한다(조회 실패="승인됨" 오인 방지).
+ $throwingTemplates = Mockery::mock(AlimtalkTemplateService::class);
+ $throwingTemplates->shouldReceive('list')
+ ->andThrow(new \Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException('자격증명 미설정'));
+
+ $service = new NotificationBindingService(
+ new BizppurioNotificationBindingRepository,
+ $throwingTemplates,
+ $this->fakeKakaoContent(),
+ );
+
+ $this->expectException(\Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException::class);
+
+ try {
+ $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '가입환영']);
+ } finally {
+ $this->assertDatabaseMissing('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'template_code' => 'TW_1',
+ ]);
+ }
+ }
+
+ public function test_apply_from_template_save는_코드가_있으면_연동을_저장한다(): void
+ {
+ $this->makeServiceWithApprovedCodes(['TW_9'])
+ ->applyFromTemplateSave('welcome', 'TW_9', '가입환영', true);
+
+ $this->assertDatabaseHas('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ 'template_code' => 'TW_9',
+ 'fallback_sms_enabled' => true,
+ ]);
+ }
+
+ public function test_apply_from_template_save는_코드가_비면_연동을_해제한다(): void
+ {
+ $service = $this->makeServiceWithApprovedCodes(['TW_1']);
+ $service->bind('welcome', ['template_code' => 'TW_1', 'template_name' => '기존']);
+
+ // 편집 모달에서 "연결 안 함"으로 저장 → 빈 코드 → 해제 (승인 검증 대상 아님)
+ $service->applyFromTemplateSave('welcome', '', null, false);
+
+ $this->assertDatabaseMissing('bizppurio_notification_bindings', [
+ 'notification_type' => 'welcome',
+ 'channel' => 'alimtalk',
+ ]);
+ }
+
+ public function test_캐시초기화는_연결된_모든_template_code를_resolver에_넘긴다(): void
+ {
+ // 서로 다른 알림에 두 템플릿을 연결.
+ $repo = new BizppurioNotificationBindingRepository;
+ $repo->upsert('order_confirmed', 'alimtalk', ['template_code' => 'TW_1', 'template_name' => 'A', 'is_active' => true]);
+ $repo->upsert('welcome', 'alimtalk', ['template_code' => 'TW_2', 'template_name' => 'B', 'is_active' => true]);
+
+ // resolver 가 실제로 받은 코드 목록을 포착.
+ $received = null;
+ $resolver = Mockery::mock(KakaoTemplateContentResolver::class);
+ $resolver->shouldReceive('clearMany')
+ ->once()
+ ->andReturnUsing(function (array $codes) use (&$received) {
+ $received = $codes;
+
+ return count($codes);
+ });
+
+ $cleared = $this->makeService([], $resolver)->clearTemplateContentCache();
+
+ $this->assertSame(2, $cleared);
+ $this->assertContains('TW_1', $received);
+ $this->assertContains('TW_2', $received);
+ }
+
+ public function test_캐시초기화는_연동이_없으면_0을_반환한다(): void
+ {
+ $this->assertSame(0, $this->makeService()->clearTemplateContentCache());
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php
new file mode 100644
index 00000000..35fb3c85
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php
@@ -0,0 +1,129 @@
+resolver = new ResultCodeResolver;
+ }
+
+ /**
+ * 성공 코드(발송 1000 / SMS 4100 / LMS 6600 / 알림톡 7000 / 카카오 200)는 Success.
+ */
+ public function test_success_codes_categorized_as_success(): void
+ {
+ foreach (['1000', '4100', '6600', '7000', '200'] as $code) {
+ $this->assertSame(ResultCategory::Success, $this->resolver->categorize($code), "코드 {$code}");
+ $this->assertTrue($this->resolver->isSuccess($code), "코드 {$code}");
+ }
+ }
+
+ /**
+ * 잔액부족(9070 문자 / 7436 알림톡 / 9071 후불 한도초과)은 BalanceLow + isBalanceLow true.
+ */
+ public function test_balance_low_codes(): void
+ {
+ foreach (['9070', '7436', '9071'] as $code) {
+ $this->assertSame(ResultCategory::BalanceLow, $this->resolver->categorize($code), "코드 {$code}");
+ $this->assertTrue($this->resolver->isBalanceLow($code), "코드 {$code}");
+ $this->assertFalse($this->resolver->isSuccess($code), "코드 {$code}");
+ }
+ }
+
+ /**
+ * 후불 한도초과(9071) 는 사유 문구가 lang 에 정의되어 코드만 노출되지 않는다.
+ */
+ public function test_postpaid_limit_code_has_reason(): void
+ {
+ $this->assertNotNull($this->resolver->reason('9071'));
+ $this->assertStringContainsString('9071', $this->resolver->label('9071'));
+ }
+
+ /**
+ * 최신 명세 정합화로 추가된 코드(IP 오류·수신거부·발신프로필 키 무효)에 사유가 정의돼
+ * 코드만 노출되지 않는다.
+ */
+ public function test_newly_added_codes_have_reason(): void
+ {
+ foreach (['3003', '3010', '4431', '7103'] as $code) {
+ $this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락");
+ }
+ }
+
+ /**
+ * 재시도 대상으로 분류된 코드는 전부 사유가 lang 에 정의돼 있어야 한다(코드값만 노출 금지).
+ *
+ * 회귀: RETRYABLE_CODES 에 5002 가 분류돼 있었으나 lang/{ko,en}/result_codes.php 와 ja
+ * 번들에 5002 키가 누락되어 label('5002') 가 사유 없이 코드만 반환하던 결함.
+ */
+ public function test_all_retryable_codes_have_reason(): void
+ {
+ foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) {
+ $this->assertNotNull($this->resolver->reason($code), "코드 {$code} 사유 누락");
+ }
+ }
+
+ /**
+ * 일시오류 코드는 Retry.
+ *
+ * 공통(5002 요청 과다·5003/5004/5005/9000/3011/3013)에 더해, 알림톡 일시오류(7306 카카오
+ * 시스템오류·7307 처리지연·7421 타임아웃·7437 메시지 요청실패)도 재시도 대상이다.
+ * 7305(성공 불확실)는 이미 발송됐을 수 있어 중복발송 위험이 있으므로 재시도 대상에서 제외한다.
+ */
+ public function test_retryable_codes_categorized_as_retry(): void
+ {
+ foreach (['5002', '5003', '5004', '5005', '9000', '3011', '3013', '7306', '7307', '7421', '7437'] as $code) {
+ $this->assertSame(ResultCategory::Retry, $this->resolver->categorize($code), "코드 {$code}");
+ }
+ }
+
+ /**
+ * 7305(성공 불확실)는 중복발송 위험으로 재시도 대상이 아니다(영구실패로 분류).
+ */
+ public function test_success_uncertain_7305_is_not_retryable(): void
+ {
+ $this->assertNotSame(ResultCategory::Retry, $this->resolver->categorize('7305'));
+ }
+
+ /**
+ * 위 분류에 없는 코드는 영구 실패.
+ */
+ public function test_unknown_and_failure_codes_are_permanent_failure(): void
+ {
+ foreach (['4400', '6606', '7204', '2000', '9999'] as $code) {
+ $this->assertSame(ResultCategory::PermanentFailure, $this->resolver->categorize($code), "코드 {$code}");
+ }
+ }
+
+ /**
+ * reason 은 lang 정의 코드는 사유를, 미정의 코드는 null 을 반환.
+ */
+ public function test_reason_resolves_defined_codes_and_null_for_unknown(): void
+ {
+ $this->assertSame('음영 지역', $this->resolver->reason('4400'));
+ $this->assertNull($this->resolver->reason('9999'));
+ }
+
+ /**
+ * label 은 정의 코드는 "사유 (코드)", 미정의 코드는 코드만 반환.
+ */
+ public function test_label_formats_reason_with_code(): void
+ {
+ $this->assertSame('음영 지역 (4400)', $this->resolver->label('4400'));
+ $this->assertSame('9999', $this->resolver->label('9999'));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php
new file mode 100644
index 00000000..ec1fe165
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php
@@ -0,0 +1,364 @@
+ $rendered replaceVariables 반환값
+ */
+ private function fakeTemplate(array $rendered = ['subject' => '', 'body' => '주문이 완료되었습니다.']): NotificationTemplate
+ {
+ $template = Mockery::mock(NotificationTemplate::class)->makePartial();
+ $template->is_active = true;
+ $template->shouldReceive('replaceVariables')->andReturn($rendered);
+
+ return $template;
+ }
+
+ /**
+ * 템플릿 resolve 결과를 지정한 driver 를 만듭니다.
+ *
+ * @param NotificationTemplate|null $template resolve 반환값
+ * @param bool $isTestMode 검수 모드 설정값 (이력 스냅샷 검증용)
+ */
+ private function makeDriver(
+ ?NotificationTemplate $template,
+ ?MessagePayloadBuilder $builder = null,
+ bool $isTestMode = true,
+ ): SmsChannelDriver {
+ $templateService = Mockery::mock(NotificationTemplateService::class);
+ $templateService->shouldReceive('resolve')
+ ->with(Mockery::any(), 'sms')
+ ->andReturn($template);
+
+ $pluginSettings = Mockery::mock(PluginSettingsService::class);
+ $pluginSettings->shouldReceive('get')
+ ->with('sirsoft-message_bizppurio', 'is_test_mode', true)
+ ->andReturn($isTestMode);
+
+ // 스킵 예외 메시지의 알림 유형 라벨 조회 — 정의 없음(null)이면 resolveTypeLabel 이 코드값으로 폴백.
+ $definitionService = Mockery::mock(NotificationDefinitionService::class);
+ $definitionService->shouldReceive('resolve')->andReturn(null);
+
+ return new SmsChannelDriver(
+ $templateService,
+ $definitionService,
+ new SmsTypeResolver,
+ $builder ?? $this->spyBuilder(),
+ new BizppurioDispatchRepository,
+ new DispatchLinkContext,
+ $pluginSettings,
+ );
+ }
+
+ /**
+ * build* 호출 인자를 기록하는 payload 빌더 스파이를 만듭니다.
+ */
+ private function spyBuilder(): MessagePayloadBuilder
+ {
+ return new class extends MessagePayloadBuilder
+ {
+ public array $calls = [];
+
+ public function __construct() {}
+
+ public function buildSms(string $to, string $message, string $refkey): array
+ {
+ $this->calls[] = ['type' => 'sms', 'to' => $to, 'message' => $message, 'refkey' => $refkey];
+
+ return ['type' => 'sms', 'to' => $to, 'refkey' => $refkey];
+ }
+
+ public function buildLms(string $to, string $message, string $refkey, ?string $subject = null): array
+ {
+ $this->calls[] = ['type' => 'lms', 'to' => $to, 'message' => $message, 'refkey' => $refkey, 'subject' => $subject];
+
+ return ['type' => 'lms', 'to' => $to, 'refkey' => $refkey];
+ }
+ };
+ }
+
+ private function notification(array $data = []): GenericNotification
+ {
+ return new GenericNotification('order_confirmed', 'sirsoft-ecommerce', $data, 'module', 'sirsoft-ecommerce');
+ }
+
+ public function test_템플릿이_없으면_예외를_던지고_발송하지_않는다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver(null)->send($member, $this->notification());
+ } finally {
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+ }
+
+ public function test_스킵_예외_메시지는_알림_유형_코드값_대신_사람이_읽는_이름을_사용한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $definition = Mockery::mock(\App\Models\NotificationDefinition::class);
+ $definition->shouldReceive('getLocalizedName')->andReturn('회원가입 환영');
+
+ $definitionService = Mockery::mock(NotificationDefinitionService::class);
+ $definitionService->shouldReceive('resolve')->with('order_confirmed')->andReturn($definition);
+
+ $templateService = Mockery::mock(NotificationTemplateService::class);
+ $templateService->shouldReceive('resolve')->with(Mockery::any(), 'sms')->andReturn(null);
+
+ $pluginSettings = Mockery::mock(PluginSettingsService::class);
+ $pluginSettings->shouldReceive('get')->andReturn(true);
+
+ $driver = new SmsChannelDriver(
+ $templateService,
+ $definitionService,
+ new SmsTypeResolver,
+ $this->spyBuilder(),
+ new BizppurioDispatchRepository,
+ new DispatchLinkContext,
+ $pluginSettings,
+ );
+
+ try {
+ $driver->send($member, $this->notification());
+ $this->fail('NotificationSendSkippedException 가 발생해야 한다.');
+ } catch (NotificationSendSkippedException $e) {
+ $this->assertStringContainsString('회원가입 환영', $e->getMessage(), '코드값(order_confirmed) 대신 사람이 읽는 이름이 노출돼야 한다.');
+ $this->assertStringNotContainsString('order_confirmed', $e->getMessage());
+ }
+ }
+
+ public function test_회원은_mobile로_발송한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+ $builder = $this->spyBuilder();
+
+ $this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification());
+
+ Bus::assertDispatched(SendMessageJob::class);
+ $this->assertCount(1, $builder->calls);
+ $this->assertSame('01012345678', $builder->calls[0]['to'], '하이픈 제거된 회원 mobile 로 발송해야 한다.');
+ }
+
+ public function test_비회원은_data의_전화번호로_발송한다(): void
+ {
+ Bus::fake();
+ $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
+ $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
+ $builder = $this->spyBuilder();
+
+ $this->makeDriver($this->fakeTemplate(), $builder)->send($guest, $this->notification($data));
+
+ Bus::assertDispatched(SendMessageJob::class);
+ $this->assertSame('01099990000', $builder->calls[0]['to']);
+ }
+
+ public function test_회원_발송_시_pending_이력을_생성하고_회원id를_기록한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678', 'name' => '김철수']);
+
+ $this->makeDriver($this->fakeTemplate())->send($member, $this->notification());
+
+ $this->assertDatabaseCount('bizppurio_dispatches', 1);
+ $dispatch = BizppurioDispatch::first();
+ $this->assertSame('pending', $dispatch->status->value);
+ $this->assertSame('01012345678', $dispatch->to_number);
+ $this->assertSame($member->id, $dispatch->to_user_id, '회원 발송은 to_user_id 를 채워야 한다.');
+ $this->assertSame('order_confirmed', $dispatch->notification_type);
+ }
+
+ public function test_비회원_발송_이력은_회원id가_null이다(): void
+ {
+ Bus::fake();
+ $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
+ $data = [SmsChannelDriver::RECIPIENT_PHONE_KEY => '010-9999-0000'];
+
+ $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification($data));
+
+ $dispatch = BizppurioDispatch::first();
+ $this->assertNotNull($dispatch);
+ $this->assertNull($dispatch->to_user_id, '비회원 발송은 to_user_id 가 null 이어야 한다.');
+ }
+
+ public function test_발송_시_실제_전송_payload가_이력에_저장된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+ $builder = $this->spyBuilder();
+
+ $this->makeDriver($this->fakeTemplate(), $builder)->send($member, $this->notification());
+
+ $dispatch = BizppurioDispatch::first();
+ $this->assertNotNull($dispatch->request_payload, '결함① — 실제 비즈뿌리오 전송 payload 가 이력에 저장돼야 한다.');
+ $this->assertArrayNotHasKey('to', $dispatch->request_payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.');
+ }
+
+ public function test_이력_저장_payload에서_개인식별_정보는_제외된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ // 실제 MessagePayloadBuilder(스파이 아님)로 진짜 조립 로직을 태워야 forHistory() 제외
+ // 규칙(to/refkey/type/message 제거)을 검증할 수 있다.
+ $pluginSettings = Mockery::mock(PluginSettingsService::class);
+ $pluginSettings->shouldReceive('get')->andReturn('');
+ $realBuilder = new MessagePayloadBuilder($pluginSettings);
+
+ $this->makeDriver($this->fakeTemplate(), $realBuilder)->send($member, $this->notification());
+
+ $dispatch = BizppurioDispatch::first();
+ $payload = $dispatch->request_payload;
+
+ $this->assertArrayNotHasKey('to', $payload, 'to 는 to_number 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertArrayNotHasKey('refkey', $payload, 'refkey 는 refkey 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertArrayNotHasKey('type', $payload, 'type 은 channel 컬럼과 중복이라 제외돼야 한다.');
+ $this->assertArrayNotHasKey('message', $payload['content']['sms'] ?? [], 'message 는 content 컬럼과 중복이라 제외돼야 한다.');
+ }
+
+ public function test_발송하지_않으면_이력도_생성하지_않는다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ // 템플릿 없음 → NotificationSendSkippedException → 이력도 없어야 함
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver(null)->send($member, $this->notification());
+ } finally {
+ $this->assertDatabaseCount('bizppurio_dispatches', 0);
+ }
+ }
+
+ public function test_전화번호가_없으면_예외를_던지고_발송하지_않는다(): void
+ {
+ Bus::fake();
+ $guest = new GuestNotifiable('guest@example.com', '홍길동', 'ko');
+
+ // data 에 전화번호 없음 + 게스트라 mobile 속성 없음
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver($this->fakeTemplate())->send($guest, $this->notification([]));
+ } finally {
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+ }
+
+ public function test_짧은_본문은_sm_s로_긴_본문은_lm_s로_보낸다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+
+ // SMS (90 byte 이하)
+ $smsBuilder = $this->spyBuilder();
+ $this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => '짧은 본문']), $smsBuilder)
+ ->send($member, $this->notification());
+ $this->assertSame('sms', $smsBuilder->calls[0]['type']);
+
+ // LMS (90 byte 초과 — 한글 45자 이상 = EUC-KR 90byte 초과)
+ $longBody = str_repeat('가', 100);
+ $lmsBuilder = $this->spyBuilder();
+ $this->makeDriver($this->fakeTemplate(['subject' => '제목', 'body' => $longBody]), $lmsBuilder)
+ ->send($member, $this->notification());
+ $this->assertSame('lms', $lmsBuilder->calls[0]['type']);
+ $this->assertSame('제목', $lmsBuilder->calls[0]['subject'], 'LMS 는 subject(코어 제목)를 재사용해야 한다.');
+ }
+
+ public function test_본문이_비어있으면_예외를_던지고_발송하지_않는다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+
+ $this->expectException(NotificationSendSkippedException::class);
+
+ try {
+ $this->makeDriver($this->fakeTemplate(['subject' => '', 'body' => ' ']))
+ ->send($member, $this->notification());
+ } finally {
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+ }
+
+ public function test_generic_notification이_아니면_무시한다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '01011112222']);
+ $other = new Notification;
+
+ $this->makeDriver($this->fakeTemplate())->send($member, $other);
+
+ Bus::assertNotDispatched(SendMessageJob::class);
+ }
+
+ public function test_정상_조건에서_발송하고_이력을_생성한다(): void
+ {
+ // 비활성 확장 채널의 발송 차단은 코어 via() 책임이며 GenericNotificationViaTest 가 검증한다.
+ // 이 드라이버 테스트는 정상 조건(활성 전제)에서의 발송·이력 생성만 검증한다.
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $this->makeDriver($this->fakeTemplate())->send($member, $this->notification());
+
+ Bus::assertDispatched(SendMessageJob::class);
+ $this->assertDatabaseCount('bizppurio_dispatches', 1);
+ }
+
+ public function test_검수_모드_설정값이_이력에_스냅샷으로_기록된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $this->makeDriver($this->fakeTemplate(), isTestMode: true)->send($member, $this->notification());
+
+ $this->assertTrue(BizppurioDispatch::first()->is_test_mode);
+ }
+
+ public function test_운영_모드_설정값도_이력에_스냅샷으로_기록된다(): void
+ {
+ Bus::fake();
+ $member = User::factory()->create(['mobile' => '010-1234-5678']);
+
+ $this->makeDriver($this->fakeTemplate(), isTestMode: false)->send($member, $this->notification());
+
+ $this->assertFalse(BizppurioDispatch::first()->is_test_mode);
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php
new file mode 100644
index 00000000..32832a0e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsTypeResolverTest.php
@@ -0,0 +1,63 @@
+resolver = new SmsTypeResolver;
+ }
+
+ public function test_영문_짧은_본문은_sms(): void
+ {
+ // 영문 45바이트 → SMS
+ $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 45)));
+ }
+
+ public function test_한글은_euckr_2바이트로_계산된다(): void
+ {
+ // 한글 45자 = 90byte → 경계 안(SMS)
+ $this->assertSame(90, $this->resolver->byteLength(str_repeat('가', 45)));
+ $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('가', 45)));
+
+ // 한글 46자 = 92byte → SMS 경계 초과 → LMS
+ $this->assertSame(92, $this->resolver->byteLength(str_repeat('가', 46)));
+ $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('가', 46)));
+ }
+
+ public function test_영문_90바이트_경계와_초과(): void
+ {
+ // 정확히 90byte → SMS
+ $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve(str_repeat('a', 90)));
+ // 91byte → LMS
+ $this->assertSame(DispatchChannel::Lms, $this->resolver->resolve(str_repeat('a', 91)));
+ }
+
+ public function test_이모지는_제거되어_byte에_포함되지_않는다(): void
+ {
+ // 영문 10 + 이모지 → 이모지 제거 후 10byte 만 계산(EUC-KR 미표현 제거)
+ $this->assertSame(10, $this->resolver->byteLength('aaaaaaaaaa😀'));
+ // 이모지만 있는 경우 0byte → SMS
+ $this->assertSame(0, $this->resolver->byteLength('😀😀'));
+ $this->assertSame(DispatchChannel::Sms, $this->resolver->resolve('😀'));
+ }
+
+ public function test_lms_한도_초과_판정(): void
+ {
+ // 영문 2000byte → 한도 이내(false)
+ $this->assertFalse($this->resolver->exceedsLmsLimit(str_repeat('a', 2000)));
+ // 2001byte → 초과(true)
+ $this->assertTrue($this->resolver->exceedsLmsLimit(str_repeat('a', 2001)));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml
new file mode 100644
index 00000000..d4f549fd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml
@@ -0,0 +1,148 @@
+# audit:allow test-scenario-coverage reason: |
+# Phase 6(재설계)는 알림톡 실발송 위임(백엔드) + 시딩 필터 훅 template 증강 + 코어 편집 모달
+# 전용 칸(즉시 저장) + 상태 배너 overlay 이다. axes cross product 의 런타임 효과는 계층별로
+# 전수 가드된다:
+# - 발송 위임: AlimtalkChannelDriverTest — binding 게이트/카카오 내용 발송/버튼 extra/대체발송/전화번호/이력/플러그인 활성.
+# - 카카오 내용 조회·캐시(kakao_content/content_cache 축): KakaoTemplateContentResolverTest — 조회/캐시 히트/TTL=0 우회/429 재시도/실패 null.
+# - 카카오→발송 변환(kakao_elements 축): AlimtalkPayloadMapperTest — 본문/버튼/바로연결/title·header·item·highlight·link 변환 + #{var} 치환 + 부재 필드 생략.
+# - 캐시 수동 초기화 엔드포인트·권한: AlimtalkTemplateControllerTest + NotificationBindingServiceTest.
+# - template 증강: SeedChannelTemplatesListenerTest — 회원판정/기본문구/채널추가/멱등.
+# - 연동 즉시저장·조회·권한(저장 시점 카카오 승인 재검증 포함): NotificationBindingServiceTest +
+# NotificationBindingEndpointTest.
+# - 채널 메타(is_test_mode): RegisterNotificationChannelsListenerTest.
+# - overlay 전용칸·배너·i18n·무오염: notification_tab_binding.test.tsx.
+# e2e_* 축(브라우저 왕복: 편집 열기→드롭다운 선택→즉시 저장→toast→배너)은 실제 카카오 발신
+# 프로필·승인 템플릿이 필요해, 계획서 §9 방침대로 PO 직접 수행하는 실연동 검증 단계에서
+# Playwright spec 을 최초 도입하며 그때 @scenario/@effects 를 이 매니페스트에 매핑한다. Vitest
+# 렌더/구조 테스트가 프론트 overlay·배너·무오염을 가드하므로 이번 Phase 는 spec 미도입
+# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 커버로 충족).
+
+feature: 비즈뿌리오 알림톡 채널 실발송 + 알림 설정 편집 모달 연동 (Phase 6 재설계)
+
+description: |
+ 회원 대상 알림에 sms·alimtalk 채널 template 을 시딩 필터 훅으로 증강하고(코어/모듈 무수정),
+ 코어 알림 편집 모달(alimtalk 채널)에 전용 칸을 얹어 승인 알림톡 템플릿을 연결한다. 전용 칸
+ 값은 바꾸면 즉시 우리 API 로 저장한다(별도 저장 버튼 없음 — 코어 저장 버튼과 무관해 코어
+ 템플릿 무오염). 이벤트 발생 시 연결된 템플릿으로 알림톡을 자동발송한다.
+
+ 핵심 동작:
+ - SeedChannelTemplatesListener: 코어/모듈 시딩 필터 훅(seed.*.notifications.translations)에
+ 편승해 회원 알림에 sms·alimtalk template 증강(cleanupStaleTemplates 에도 유지).
+ - AlimtalkChannelDriver: to_alimtalk 발송 위임. binding 없으면 skip. 플러그인 비활성 시 차단.
+ - 카카오 내용 반영(B안): 발송 본문·버튼·요소를 카카오 승인 템플릿(templateContent/buttons/
+ quickReplies/title/header/item/itemHighlight/representLink)에서 상세조회로 가져와 채운다.
+ 비즈뿌리오 발송 API 는 완성본을 요구하므로 templatecode 만으로는 안 된다.
+ · KakaoTemplateContentResolver: 상세조회 + template_code 단위 캐시(기본 1h, TTL=0 끔,
+ 429/5002 rate limit 시 1회 재시도, 실패 null → skip).
+ · AlimtalkPayloadMapper: 카카오 필드 → 발송 형식 변환(linkMo→url_mobile 등) + #{var} 치환.
+ · 캐시 수동 초기화 엔드포인트/버튼(manage 권한): 카카오 변경 즉시 반영.
+ - 대체발송: binding.fallback_sms_enabled ON → payload 에 resend:{first:sms}+recontent 병합.
+ 대체 SMS 본문은 카카오와 무관하게 코어 alimtalk 템플릿에서 렌더(빈 본문이면 미병합).
+ - 행 연결 UI(extension_point notification_definition_row_footer): 알림톡 탭의 각 알림 행 하단에
+ 연결 상태 줄(템플릿명·SMS대체) + [연결/변경] 버튼. channel==='alimtalk' 일 때만.
+ - 연결 모달(overlay modals, 우리 소유): 코어 편집 모달과 분리. 안내 카드 + 연결 템플릿
+ 드롭다운(승인 RDY/ACT만) + SMS 대체 토글 + 변수 안내 + [취소][저장]. [저장]→우리 API(store)
+ + toast + 모달 닫힘 + 목록 갱신. 카카오 API 422 는 errorHandling.suppress 로 조용히 처리.
+ - 상태 배너(overlay injections): sms·alimtalk 탭에서 readiness 미충족 🔴설정하기 / is_test_mode 🟡.
+ - 안내 박스(overlay): 알림톡 탭 상시 안내(무엇을 하는 화면인지).
+
+axes:
+ channel: [alimtalk]
+ recipient: [member, guest]
+ binding_state: [bound, unbound]
+ fallback_sms: [on, off]
+ member_facing: [member_target, admin_only]
+ banner_state: [not_ready, test_mode, normal]
+ e2e_binding_action: [connect, change, unbind]
+ # B안 — 카카오 내용 반영·캐시
+ kakao_content: [fetched, orphan_or_error, rate_limited]
+ kakao_elements: [body_only, with_button, with_quickreply, with_title, with_item, with_link]
+ content_cache: [hit, miss, ttl_zero, manual_cleared]
+
+exclusions:
+ - { binding_state: unbound, fallback_sms: on, reason: "미연결 알림은 대체발송 설정 자체가 없음" }
+ - { member_facing: admin_only, binding_state: bound, reason: "관리자 전용 알림은 증강 대상 아님 — 연동 불가" }
+ - { recipient: guest, e2e_binding_action: connect, reason: "연동 저장 UI 는 관리자 화면 — 수신자(member/guest)는 발송 축, UI 축과 무관" }
+ - { kakao_content: orphan_or_error, kakao_elements: with_button, reason: "조회 실패 시 내용 자체가 없어 요소 축 무의미 — skip 으로 수렴" }
+ - { kakao_content: rate_limited, kakao_elements: with_button, reason: "rate limit 은 조회 단계 — 재시도 소진 시 null → skip, 요소 축 무의미" }
+ - { content_cache: manual_cleared, kakao_elements: with_item, reason: "캐시 초기화는 조회 시점 축 — 요소 종류와 직교, 대표 조합만 커버" }
+
+effects:
+ # 발송 위임 (AlimtalkChannelDriver)
+ - alimtalk_dispatched_with_bound_template_code
+ - dispatch_skipped_when_no_binding
+ - dispatch_skipped_when_plugin_inactive
+ - dispatch_skipped_when_no_phone
+ - member_phone_resolved_from_mobile
+ - guest_phone_resolved_from_data_recipient_phone
+ - fallback_on_merges_sms_resend_recontent
+ - fallback_off_has_no_resend
+ - fallback_sms_body_from_core_template_not_kakao
+ - fallback_skipped_when_core_template_missing
+ - pending_dispatch_created_with_alimtalk_channel
+ # 카카오 내용 반영 (KakaoTemplateContentResolver + AlimtalkPayloadMapper) — B안
+ - alimtalk_body_from_kakao_template_content
+ - dispatch_skipped_when_kakao_content_unavailable
+ - dispatch_can_send_without_core_alimtalk_template
+ - kakao_variables_substituted_from_notification_data
+ - kakao_buttons_mapped_to_send_form_with_url_substitution
+ - kakao_quickreplies_mapped_to_send_form
+ - kakao_title_header_item_highlight_link_mapped
+ - absent_kakao_fields_omitted_from_payload
+ - content_cache_hit_skips_kapi_call
+ - content_cache_ttl_zero_bypasses_cache
+ - content_cache_rate_limit_retried_then_null
+ - manual_cache_clear_forgets_bound_template_codes
+ - manual_cache_clear_requires_manage_permission
+ # template 증강 (SeedChannelTemplatesListener)
+ - member_facing_notification_gets_sms_and_alimtalk_channels
+ - admin_only_notification_not_augmented
+ - augmented_body_reuses_database_plaintext
+ - augmented_body_strips_html_when_no_database
+ - existing_channel_not_duplicated
+ - module_sequential_array_augmented
+ # 채널 메타 (RegisterNotificationChannelsListener)
+ - channel_meta_carries_is_test_mode
+ - test_mode_off_sets_is_test_mode_false
+ # 연동 즉시 저장·조회·권한 (NotificationBindingService/Controller)
+ - store_creates_or_updates_binding_immediately
+ - store_empty_code_unbinds
+ - only_approved_templates_offered_as_options
+ - bind_rejects_unapproved_template_code
+ - bind_rejects_when_kakao_lookup_fails
+ - bindings_map_returned_for_prefill
+ - view_permission_required_for_read
+ - manage_permission_required_for_store
+ # 행 연결 UI·연결 모달·배너 (프론트 — Vitest 구조/i18n)
+ - row_binding_shown_only_for_alimtalk_channel
+ - row_shows_bound_or_unbound_status_from_bindings
+ - connect_button_seeds_modal_and_opens_our_modal
+ - openmodal_runs_before_approved_refetch
+ - our_binding_modal_registered_via_modals
+ - fallback_toggle_disabled_when_no_template
+ - save_posts_to_our_api_then_toast_close_refresh
+ - approved_templates_422_suppressed
+ - banner_shown_only_on_sms_alimtalk_tab_when_problem
+ - not_ready_banner_has_setup_action
+ - test_mode_banner_shown_when_ready_and_test_mode
+ - alimtalk_tab_guide_box_shown
+ - core_edit_modal_untouched_no_bizppurio_fields
+ # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상)
+ - e2e_open_alimtalk_edit_shows_section
+ - e2e_select_template_saves_and_toasts
+ - e2e_unbind_via_none_option_removes_binding
+
+test_files:
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkChannelDriverTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/AlimtalkPayloadMapperTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/AlimtalkTemplate/AlimtalkTemplateControllerTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/SeedChannelTemplatesListenerTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx
+ - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx
+
+audit:
+ status: codified
\ No newline at end of file
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml
new file mode 100644
index 00000000..48bb8ddb
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml
@@ -0,0 +1,93 @@
+# audit:allow test-scenario-coverage reason: |
+# 알림톡 템플릿 화면은 설정 페이지(plugin_settings.json)의 탭으로 배치되며(독립 페이지·메뉴
+# 없음), kapi 실시간 위임이라 DB 저장이 없다. 조회 전용 전환으로 등록·수정·삭제·검수·상태변경을
+# 제거하고 목록·상태·내용 조회 + 알림 연결만 남겼다(등록·관리는 비즈뿌리오 콘솔로 위임). axes
+# cross product 의 UI 구조(탭 전환·목록 서브뷰·상세 모달·콘솔/준비 안내·i18n) effects 는 Vitest
+# (alimtalk_templates.test.tsx) 정적 검증으로 가드된다. 조회 권한 경계(view)·등록 라우트 제거(405)·
+# kapi 실패 전파(422)는 PHPUnit Feature(AlimtalkTemplateControllerTest) 로, 상태 배지 매핑·kapi
+# 파라미터 전달은 Unit(AlimtalkTemplateServiceTest) 로 Http::fake 왕복 관찰한다. 런타임 Playwright
+# spec 은 실연동 검증(PO 직접 수행, 카카오 관리 API 자격증명 필요) 단계에서 최초 도입한다.
+
+feature: 알림톡 템플릿 조회 (조회 전용)
+
+description: |
+ 설정 페이지 알림톡 템플릿 탭에서 카카오 관리 API(kapi.ppurio.com)로 알림톡 템플릿을
+ 실시간 조회한다(목록·상태·내용). DB 저장 없이 매 요청 kapi 위임이며, 등록·수정·삭제·검수·
+ 상태변경은 비즈뿌리오 콘솔로 위임한다. 이 화면의 고유 가치는 카카오 템플릿 ↔ G7 알림 연결
+ (binding)이며, 진입은 코어 소유 설정 페이지 하나로 통일한다(메뉴 없음).
+
+ 핵심 동작:
+ - 탭 네비게이션: 환경설정 ↔ 알림톡 템플릿 (query.tab, 배타 전환)
+ - 목록 서브뷰: 상태 필터(templateStatus) + 검색(keyword) + 상태 배지(serviceStatus) + [내용]
+ - 상세 모달: 내용만 표시 + [닫기] (관리 액션 없음)
+ - 권한: 조회=messaging.view (변경 라우트는 제거 — 등록은 콘솔)
+ - kapi 실패(발신프로필 미설정·조회 실패)는 그대로 422 로 전파(운영자가 사유 즉시 확인)
+ - readiness: api_key·sender_key 미설정 시 안내 + [설정하기] 탭 이동
+ - 안내: 목록 상단 콘솔 위임 안내 + 환경설정 탭 사용 전 준비 안내(카카오·SMS)
+
+axes:
+ auth: [view, forbidden, guest]
+ kapi_result: [ok, fail, not_found]
+
+exclusions:
+ - { auth: guest, kapi_result: fail, reason: "비인증은 결과 무관 401 — kapi 도달 전 차단" }
+ - { auth: forbidden, kapi_result: fail, reason: "권한 없으면 403 — kapi 도달 전 차단" }
+ - { auth: guest, kapi_result: not_found, reason: "비인증은 결과 무관 401 — kapi 도달 전 차단" }
+ - { auth: forbidden, kapi_result: not_found, reason: "권한 없으면 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
+ - init_actions_loads_list_when_entering_templates_tab
+ # 데이터소스 (UI) — 조회 전용, 카테고리 데이터소스 제거
+ - templates_datasource_targets_admin_api
+ - category_datasource_removed_read_only
+ # 조회 전용 — 폼·관리 제거 (UI)
+ - form_view_removed
+ - action_modal_removed
+ - form_and_manage_state_absent_from_layout
+ # 목록 서브뷰 (UI)
+ - toolbar_has_status_filter_and_keyword_search
+ - toolbar_has_no_new_template_button_refresh_only
+ - table_card_always_shows_header_and_branches_tbody_by_count
+ - table_iterates_templates_with_status_badge
+ - manage_column_is_single_content_button_opening_detail_modal
+ - list_has_row_number_requested_at_processed_at_columns
+ - status_badge_appends_rdy_sub_label_only_for_rdy
+ - top_level_actions_all_have_event_type
+ - pagination_updates_page_state_and_refetches
+ # 안내 (UI)
+ - list_notice_shows_badge_meaning_and_console_delegation
+ - settings_tab_shows_preparation_notice_kakao_and_sms
+ # 상세 모달 & readiness (UI)
+ - detail_modal_defined
+ - detail_modal_shows_status_badge_and_content_close_only
+ - readiness_notice_shown_when_ready_flag_false_via_readiness_api
+ # 권한 경계 (백엔드)
+ - list_returns_templates_with_status_badge
+ - show_returns_template_detail_with_status_badge
+ - list_requires_authentication_returns_401
+ - list_requires_view_permission_returns_403
+ - store_route_removed_returns_405_read_only
+ # 상태 배지 & kapi 전달 (백엔드)
+ - service_status_maps_to_badge_without_available_actions
+ - status_badge_label_key_uses_frontend_lang_format
+ - detail_derives_status_from_inspection_and_status
+ - list_passes_status_keyword_to_kapi
+ # kapi 실패 전파 (백엔드)
+ - kapi_failure_is_surfaced_as_422_with_result_code
+ - sender_key_missing_throws
+ # kapi 508(요청한 데이터가 없음=검색 결과 0건) 은 에러가 아니라 빈 목록으로 처리 (백엔드)
+ - list_treats_kapi_508_as_empty_result_not_error
+ # 다국어
+ - 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
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml
new file mode 100644
index 00000000..9d04e6b5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-history-webhook.yaml
@@ -0,0 +1,74 @@
+# audit:allow scenario-scale-axis-required reason: SendMessageJob 은 알림 1건당 1 dispatch(payload 1건·refkey 1건)로 볼륨 무관 상수 메모리다. 루프/chunk/대량 적재가 없어 #79 형 붕괴 구조가 아니므로 scale 축이 무의미하다.
+# audit:allow test-scenario-coverage reason: |
+# 발송 결과 기록·webhook 수신·결과코드 해석의 axes cross product 는 PHPUnit
+# (BizppurioWebhookTest·ResultCodeResolverTest·SmsChannelDriverTest·SendMessageJobTest·
+# BizppurioDispatchTest·리포지토리·Installation)로 실제 HTTP/DB 왕복을 관찰한다.
+# 발송 이력의 "표시 화면"은 이번 단계에 두지 않는다 — 코어 알림 발송 이력 화면에 비즈뿌리오
+# 결과(도달·실패사유)를 얹는 방식(A-2, 후속 Phase)으로 이관하기로 결정했기 때문이다.
+# 따라서 이 매니페스트는 결과 저장·수신·해석까지만 커버하며, 표시 UI axis/effects 는
+# A-2 착수 시 그 화면(코어 이력 + 주입)에 대한 매니페스트로 추가한다.
+
+feature: 비즈뿌리오 발송 결과 기록 + webhook 수신 + 결과코드 (Phase 4)
+
+description: |
+ 발송 결과를 bizppurio_dispatches 에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로 상태를
+ 갱신하며, 결과코드를 사유+분류로 해석한다. (표시 화면은 A-2 후속에서 코어 이력에 통합)
+
+ 핵심 동작:
+ - 발송 시 SmsChannelDriver 가 pending 이력 생성(회원=to_user_id, 비회원=null)
+ - SendMessageJob 이 발송 응답으로 sent(+messagekey) 또는 failed(+result_code) 갱신
+ - webhook: IP 화이트리스트(115.71.53.78/79/94/95, 환경 우회 없음) → refkey 매칭 →
+ replay 멱등(reported_at) → RESULT 분류 → 상태전이 + media/fallback/raw_payload/reported_at
+ - 잔액부족(9070 문자 / 7436 알림톡): failed 뒤집기 + 관리자 자체알림(쿨다운 내 1회)
+ - webhook 은 코어 토큰/IDV 미들웨어 제외(라우트 withoutMiddleware) — 비인증 실 POST 200
+
+axes:
+ channel: [sms, lms, alimtalk]
+ result_category: [success, failure, retry, balance_low]
+ recipient_kind: [member, guest]
+ webhook_ip: [allowed, blocked]
+
+exclusions:
+ - { webhook_ip: blocked, result_category: success, reason: "차단 IP 는 결과코드와 무관하게 403 — 분류 조합 무의미" }
+ - { channel: alimtalk, result_category: retry, reason: "알림톡 발송 드라이버는 Phase 6 — 이번 범위는 채널 등록/이력 스키마까지" }
+
+effects:
+ # 발송 시 이력 생성 (백엔드)
+ - sms_send_creates_pending_dispatch_with_member_user_id
+ - guest_send_creates_pending_dispatch_with_null_user_id
+ - skip_send_creates_no_dispatch
+ # 발송 응답 → 이력 갱신 (백엔드)
+ - send_success_marks_sent_and_stores_messagekey
+ - send_permanent_failure_marks_failed_with_result_code
+ - send_retryable_throws_for_queue_retry
+ # webhook 수신 (백엔드)
+ - allowed_ip_success_report_updates_status_to_success
+ - allowed_ip_failure_report_marks_failed
+ - blocked_ip_returns_403_and_leaves_dispatch_unchanged
+ - unknown_refkey_absorbed_with_200
+ - replay_report_is_idempotent
+ - balance_low_marks_failed_and_fires_admin_notification_once
+ - balance_low_notification_deduplicated_within_cooldown_but_all_dispatches_marked_failed
+ - webhook_bypasses_auth_middleware_returns_200
+ # 결과코드 해석 (백엔드)
+ - success_codes_categorized_as_success
+ - balance_low_codes_9070_7436_categorized_as_balance_low
+ - retryable_codes_categorized_as_retry
+ - unknown_codes_categorized_as_permanent_failure
+ - label_formats_reason_with_code
+ # DB (마이그레이션)
+ - phase4_tables_created_with_expected_columns
+ - migration_roundtrip_up_down_up_preserves_repository_ops
+
+test_files:
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Webhook/BizppurioWebhookTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/ResultCodeResolverTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Jobs/SendMessageJobTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioDispatchTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php
+
+audit:
+ status: codified
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml
new file mode 100644
index 00000000..72b15fe0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/dispatch-result-injection.yaml
@@ -0,0 +1,91 @@
+# audit:allow test-scenario-coverage reason: |
+# A-2 는 코어 알림 발송 이력 화면에 비즈뿌리오 결과(도달·실패사유·잔액부족·대체발송)를 얹는
+# 작업이다(코어 앱·코어 테이블 무수정). axes cross product 의 런타임 효과는 계층별로 전수 가드된다:
+# - 연결고리(코어 로그↔dispatch): NotificationLogLinkTest — 실제 훅 체인(after_log_sent/
+# after_log_failed)으로 dispatch.notification_log_id 연결, 비-비즈뿌리오 로그 미연결, refkey
+# consume 후 비워짐(오매칭 방지)까지 관찰.
+# - 링크·배치조회 Repository: BizppurioDispatchRepositoryTest — link/미존재/keyed/빈배열.
+# - 결과 표시 파생(상태·사유(코드)·잔액부족·대체발송·미매칭 제외): DispatchResultServiceTest.
+# - 결과 조회 API(권한·검증·성공/실패/잔액부족/미매칭): DispatchResultEndpointTest.
+# - overlay 결과 컬럼 실제 렌더(성공/잔액부족/대체발송/빈셀 조건부 렌더): notification_log_result.test.tsx.
+# e2e_* 축(브라우저 왕복: 코어 이력 화면 진입→결과 컬럼 확인→회원 링크/배지)은 실제 발송·webhook
+# 왕복 결과가 필요해, 계획서 §9 방침대로 PO 직접 수행하는 실연동 검증 단계에서 Playwright spec 을
+# 그때 @scenario/@effects 로 이 매니페스트에 매핑한다. Vitest 렌더 테스트가 프론트 overlay 컬럼
+# 주입·조건부 렌더를 가드하므로 이번 단계는 spec 미도입
+# (frontend-change-requires-e2e 는 이 매니페스트의 e2e axis + Vitest 렌더 커버로 충족).
+
+feature: 비즈뿌리오 발송 결과를 코어 알림 발송 이력 화면에 주입 (A-2)
+
+description: |
+ 비즈뿌리오로 보낸 문자·알림톡의 실제 결과(성공/실패·사유·잔액부족·대체발송)를 코어 "알림 발송
+ 이력" 화면의 각 행에 붙여 한 화면에서 본다. 독립 페이지·메뉴 없음. 코어 앱·코어 테이블 무수정 —
+ 연결 표식은 비즈뿌리오 쪽(bizppurio_dispatches.notification_log_id)에만 두고, layout_extensions
+ overlay 로 결과 컬럼을 코어 이력 datagrid 에 _append 한다.
+
+ 핵심 동작:
+ - 연결고리: 채널 드라이버가 발송 사이클에서 dispatch(refkey) 생성 후 DispatchLinkContext 에
+ refkey 를 남기고, 직후 발화하는 코어 after_log_sent/after_log_failed 훅에서
+ LinkNotificationLogListener 가 그 refkey 로 dispatch 에 코어 로그 id 를 기록한다(직접 표식,
+ 복합키 근사 매칭 아님 — 동일 시각 중복 발송 오매칭 방지).
+ - 결과 조회 API: 코어 이력 화면이 현재 페이지 로그 id 배열을 POST 로 넘기면(N+1 회피) 결과 맵
+ 반환. 상태·`사유 (코드)`(ResultCodeResolver 재사용)·잔액부족 여부·대체발송 상태. 전화번호 등
+ 민감정보 미포함(결과 컬럼엔 노출 안 함).
+ - overlay: 코어 datagrid columns 에 결과 컬럼 1개 _append. row.id 로 결과 맵 매칭. 매칭 안 되는
+ 행(메일·DB 등 비-비즈뿌리오)은 빈 셀(-).
+
+axes:
+ channel: [sms, lms, alimtalk]
+ result_category: [success, failure, balance_low]
+ fallback: [none, present]
+ recipient_kind: [member, guest]
+ link_outcome: [linked, unmatched]
+ e2e_view_action: [open_history, view_result_cell, open_member]
+
+exclusions:
+ - { link_outcome: unmatched, result_category: success, reason: "미매칭(비-비즈뿌리오) 행은 결과 자체가 없어 result_category 조합 무의미 — 빈 셀 한 케이스로 충분" }
+ - { link_outcome: unmatched, fallback: present, reason: "미매칭 행은 대체발송 결과도 없음" }
+ - { recipient_kind: guest, e2e_view_action: open_member, reason: "비회원은 회원 상세 링크 없음(일반 텍스트) — 회원 축과 무관" }
+
+effects:
+ # 연결고리 (백엔드 — 실제 훅 체인)
+ - sent_log_links_dispatch_via_refkey_in_same_cycle
+ - failed_log_links_dispatch_via_refkey_in_same_cycle
+ - non_bizppurio_log_leaves_dispatch_unlinked
+ - refkey_consumed_once_prevents_cross_cycle_mismatch
+ # 링크·배치조회 Repository (백엔드)
+ - link_notification_log_by_refkey_sets_log_id
+ - link_missing_refkey_returns_false
+ - find_by_log_ids_returns_keyed_map_excluding_unmatched
+ # 결과 표시 파생 (백엔드)
+ - success_result_derives_status_and_reason_code_label
+ - balance_low_result_flags_is_low_balance_true
+ - fallback_status_surfaced_in_result
+ - missing_result_code_yields_null_result_label_with_status_label
+ - unmatched_log_id_excluded_from_result_map
+ - result_map_excludes_recipient_phone_masking_not_needed
+ # 결과 조회 API (백엔드)
+ - lookup_requires_messaging_view_permission
+ - lookup_requires_log_ids_array
+ - lookup_returns_results_keyed_by_log_id
+ # overlay 결과 컬럼 렌더 (프론트)
+ - overlay_appends_result_column_to_core_history_datagrid
+ - result_column_batch_fetches_by_current_page_log_ids
+ - success_cell_renders_reason_code_label
+ - balance_low_cell_renders_low_balance_badge
+ - non_low_balance_cell_omits_low_balance_badge
+ - fallback_cell_renders_fallback_badge
+ - unmatched_row_renders_empty_cell
+ # e2e (실연동 단계 도입 예정 — 브라우저 왕복 커버 대상)
+ - e2e_open_history_shows_result_column
+ - e2e_result_cell_shows_status_reason_and_badges
+ - e2e_member_recipient_links_to_user_detail
+
+test_files:
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationLogLinkTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/DispatchResultEndpointTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/DispatchResultServiceTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioDispatchRepositoryTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_log_result.test.tsx
+
+audit:
+ status: codified
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml
new file mode 100644
index 00000000..7c0cc219
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/plugin-settings.yaml
@@ -0,0 +1,112 @@
+# audit:allow test-scenario-coverage reason: |
+# 환경설정 화면의 입력·저장은 코어 /api/admin/plugins/{id}/settings 에 위임되는 자동바인딩
+# 폼이라, axes cross product 의 UI 구조·검증·i18n effects 는 Vitest(plugin_settings.test.tsx)
+# 정적 검증으로 가드된다. 검수 모드 off(운영) 조건부 검증 왕복(422/200)과 리포트 수신 주소
+# 조회는 PHPUnit Feature(BizppurioLiveModeSettingsValidationTest·ReportUrlEndpointTest) 로
+# 실제 HTTP 왕복을 관찰한다. (§529) 연결 확인 API 왕복(200/422 + result_code)은
+# TokenCheckEndpointTest, 저장 시 토큰 캐시 무효화는 실제 훅 체인(core.plugin_settings.
+# after_save) + 실제 CacheInterface 로 InvalidateTokenOnSettingsSaveListenerTest 가,
+# verifyCredentials/describeFailure 는 BizppurioTokenServiceTest 가 각각 관찰한다.
+# 런타임 Playwright spec 은 계획서 §9 방침에 따라 실연동 검증(PO 직접 수행, 인증·발송
+# 환경 필요) 단계에서 최초 도입하며, 그 시점에 @scenario/@effects docblock 완전 매핑을
+# 추가한다. 그때까지 매니페스트는 명세로 유지한다.
+
+feature: 비즈뿌리오 메시지 발송 환경설정 (Phase 1)
+
+description: |
+ 관리자 환경설정 화면(admin/plugin_settings.json)에서 비즈뿌리오 연동 정보를 입력·저장한다.
+ 코어 플러그인 설정 인프라(/api/admin/plugins/{id}/settings)에 자동바인딩(_local.form)으로
+ 위임하며, 상단 안내 패널 + 2 섹션(API 연동 / 발송 설정) + 리포트 수신 설정 카드로 구성된다.
+
+ 핵심 동작:
+ - 검수 모드: is_test_mode Toggle (독립 카드). 켜짐=검수(dev) 도메인, 꺼짐=운영(live) 도메인
+ - API 연동: 비즈뿌리오 아이디 + 비즈뿌리오 모듈 비밀번호 + API 키
+ - 발송 설정: 발신번호 + 알림톡 발신프로필 키
+ - 검수 모드를 끄면(운영) 경고 박스를 표시하고, 저장 시 발송 필수 자격증명
+ (bizppurio_id/password/sender_number)을 required 로 강제(조건부 검증 필터 훅)
+ - 저장 실패 시 각 필드 하단에 인라인 에러 메시지 표시
+ - 리포트 수신 설정: 발송 결과 수신 주소를 사이트 도메인 기준 절대 URL 로 조회·복사
+ - 크리덴셜(password/api_key/sender_key)은 type=password 마스킹 + frontend_schema expose:false
+ - 저장은 hasChanges 없으면 비활성, 있으면 코어 설정 API 로 PUT
+ - 설치·활성화 시 권한 2개(view/manage)·잔액부족 알림 정의 생성 + 설정 초기값 로드
+ (관리자 메뉴는 추가하지 않음 — ⚑ 결정 3, 진입은 코어 소유 설정 페이지 하나)
+ - (§529) 저장 성공 시 인증 토큰 캐시를 무조건 무효화 — 비밀번호 변경 후 옛 자격증명으로
+ 발급된 토큰이 만료(23h)까지 재사용되는 것을 방지
+ - (§529) "연결 확인" 버튼 — 비밀번호 필드와 분리된 독립 필드. 저장된 자격증명으로
+ 캐시를 거치지 않고 즉시 `/v1/token` 재검증. hasChanges=true(미저장 변경 존재)면
+ API 호출 없이 "먼저 저장해주세요" toast 만 표시. 결과는 toast 로만 안내(상시 표시 없음)
+
+ ※ 리포트 실제 수신 처리(POST /webhook)·알림톡 채널 기본 body 시드는 Phase 4~6 으로 이관.
+
+axes:
+ credential_state: [empty, filled]
+ test_mode: [on, off]
+ field_kind: [credential, plain, readonly]
+ connection_check_result: [valid_credentials, invalid_credentials, unsaved_changes]
+
+exclusions:
+ - { field_kind: readonly, credential_state: filled, reason: "리포트 수신 주소는 서버 조회 표시값 — 운영자 입력 대상 아님" }
+ - { connection_check_result: unsaved_changes, credential_state: empty, reason: "unsaved_changes 분기는 hasChanges 게이트만 검증 — 자격증명 값 자체는 무관(가드가 API 호출 자체를 막음)" }
+
+effects:
+ # 섹션 구조
+ - info_panel_test_mode_api_sending_report_sections_present
+ - test_mode_card_has_is_test_mode_toggle
+ - test_mode_card_shows_separate_account_notice_always
+ - live_mode_warning_shown_when_test_mode_off
+ # 입력 필드
+ - five_input_fields_bound_by_name
+ - credential_fields_masked_as_password
+ - plain_fields_use_text_input
+ - inline_error_node_present_per_field
+ # 조건부 검증 (백엔드)
+ - test_mode_off_empty_credentials_return_422_for_bizppurio_id_password_sender_number
+ - test_mode_off_does_not_require_api_key_or_sender_key
+ - test_mode_on_empty_credentials_return_200
+ - test_mode_off_filled_credentials_return_200
+ - validation_error_messages_use_localized_field_labels
+ - test_mode_off_selects_live_domain_test_mode_on_selects_dev_domain
+ # 리포트 수신 설정
+ - report_url_endpoint_returns_absolute_url_from_app_url
+ - report_url_endpoint_requires_authentication
+ - report_section_shows_readonly_url_with_copy_button
+ # 저장
+ - save_button_disabled_without_changes
+ - save_puts_to_core_plugin_settings_api
+ - only_registered_handlers_used
+ # 연결 확인 + 저장 시 토큰 캐시 무효화 (§529)
+ - password_field_label_distinguishes_module_password_from_g7_login_password
+ - connection_check_field_is_separate_node_from_password_field
+ - connection_check_button_shows_plug_icon_idle_spinner_icon_loading
+ - connection_check_calls_token_check_api_only_when_no_unsaved_changes
+ - connection_check_shows_unsaved_changes_toast_without_api_call_when_has_changes
+ - connection_check_result_shown_via_toast_only_no_persistent_indicator
+ - token_check_endpoint_returns_200_on_valid_credentials
+ - token_check_endpoint_returns_422_with_bizppurio_reason_and_result_code_on_failure
+ - token_check_endpoint_requires_authentication_and_manage_permission
+ - settings_save_success_invalidates_token_cache_regardless_of_changed_fields
+ - settings_save_failure_does_not_invalidate_token_cache
+ - other_plugin_settings_save_does_not_invalidate_this_plugin_token_cache
+ # 탭 전환 (환경설정 ↔ 알림톡 템플릿) — query.tab 변경 시 if 조건부 패널 재평가 필요
+ - tab_buttons_use_navigate_not_replaceurl # replaceUrl 은 URL만 바꿔 if 재평가 없음 → 화면 미전환 회귀
+ - alimtalk_template_list_error_shown_with_kakao_reason # 목록 조회 실패 시 카카오 사유(kakao_message)를 배너로 노출
+ # 다국어
+ - all_referenced_i18n_keys_exist_in_ko_and_en
+ # 설치·활성화 (백엔드)
+ - install_creates_view_and_manage_permissions
+ - activate_does_not_create_admin_menus # ⚑ 결정 3: 플러그인 메뉴 폐기, 진입은 설정 페이지 하나로 통일
+ - balance_low_notification_targets_admin
+ - settings_defaults_load_with_test_mode_on
+
+test_files:
+ - plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/BizppurioLiveModeSettingsValidationTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/ReportUrlEndpointTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Settings/TokenCheckEndpointTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Installation/InstallationTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/EnumTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/InvalidateTokenOnSettingsSaveListenerTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTokenServiceTest.php
+
+audit:
+ status: codified
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml
new file mode 100644
index 00000000..623b6534
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/sms-channel-dispatch.yaml
@@ -0,0 +1,77 @@
+# audit:allow test-scenario-coverage reason: |
+# Phase 3 는 코어 알림 채널 등록 + 문자 발송 위임 배선(백엔드)이다. axes cross product 의
+# 런타임 효과(채널 노출/readiness/전화번호 해석/발송 Job 위임)는 PHPUnit 로 전수 가드된다:
+# RegisterNotificationChannelsListenerTest(14) + SmsChannelDriverTest(7) +
+# GuestPhoneExtractListenerTest(6) + ChannelRegistrationHookTest(4, 실제 훅 체인).
+# 프론트 변경 없음(레이아웃 JSON/컴포넌트 미변경) → frontend-change-requires-e2e 비대상이라
+# Playwright spec 은 이번 Phase 미도입. 실발송 왕복(실제 API·webhook)은 계획서 §9 방침대로
+# PO 직접 수행하는 실연동 검증 단계에서 spec 최초 도입하며, 그때 @scenario/@effects 를 매핑한다.
+
+feature: 비즈뿌리오 문자 채널 등록 + 자동발송 (Phase 3)
+
+description: |
+ 코어 알림 시스템에 sms·alimtalk 채널을 등록(코어 무수정 — filter 훅)하고, 3영역
+ (코어/게시판/이커머스) 알림 이벤트 발생 시 문자를 자동발송한다. 실제 발송 실행·재시도는
+ Phase 2 SendMessageJob 에 위임한다.
+
+ 핵심 동작:
+ - filter_available_channels: sms·alimtalk 메타 노출(name_key/allow_guest:true)
+ - channel_readiness: 채널별 환경설정 충족 판정(sms=아이디+비번+발신번호, alimtalk=+api_key+sender_key)
+ - {core.auth, sirsoft-ecommerce, sirsoft-board}.notification.channels: 3영역 후보 확장
+ - ChannelManager::extend('sms'): SmsChannelDriver 로 발송 위임(alimtalk 은 Phase 6, no-op 스텁)
+ - 전화번호 해석: 회원=users.mobile / 비회원=data 의 _recipient_phone(D1)
+ - 비회원 전화번호 주입: 이커머스 extract_data 구독으로 orderer_phone → data(GuestPhoneExtractListener)
+ - sms 템플릿 없으면 발송 skip(본문 시드는 Phase 6) / 본문 byte 로 SMS·LMS 판별
+
+ ※ alimtalk 실발송·탭 UI·본문 시드는 Phase 5·6. 발송 이력 DB(bizppurio_dispatches) 영속화는 Phase 4.
+
+axes:
+ channel: [sms, alimtalk]
+ recipient: [member, guest]
+ readiness_state: [ready, missing_credentials, missing_sender]
+ test_mode: [on, off]
+
+exclusions:
+ - { channel: alimtalk, recipient: guest, reason: "알림톡 실발송은 Phase 6 — 이번 Phase 는 채널 등록/readiness 만" }
+ - { readiness_state: missing_sender, channel: alimtalk, reason: "alimtalk readiness 는 자체 키(api_key/sender_key) 부족을 별도 판정 — sender 축은 sms 로 대표" }
+
+effects:
+ # 채널 등록 (filter_available_channels)
+ - sms_channel_exposed_in_available_channels
+ - alimtalk_channel_exposed_in_available_channels
+ - both_channels_allow_guest_true
+ - alimtalk_has_no_uses_custom_list_flag_yet
+ - existing_channel_not_duplicated
+ # readiness (channel_readiness)
+ - sms_ready_when_id_password_sender_number_set
+ - sms_not_ready_reports_credentials_missing_key
+ - sms_not_ready_reports_sender_number_missing_key
+ - alimtalk_not_ready_when_api_key_or_sender_key_missing
+ - non_owned_channel_readiness_passes_through
+ # 3영역 후보 (prefix.notification.channels)
+ - three_prefixes_subscribed_core_auth_ecommerce_board
+ - channel_candidates_deduplicated
+ # 발송 위임 (SmsChannelDriver)
+ - member_phone_resolved_from_mobile
+ - guest_phone_resolved_from_data_recipient_phone
+ - dispatch_skipped_when_no_template
+ - dispatch_skipped_when_no_phone
+ - dispatch_skipped_when_body_blank
+ - short_body_dispatched_as_sms
+ - long_body_dispatched_as_lms_with_subject
+ - send_message_job_dispatched_with_refkey
+ # 게스트 전화번호 주입 (extract_data)
+ - guest_order_phone_injected_into_data
+ - member_order_phone_not_injected
+ - non_order_notification_passthrough
+ # 훅 위생
+ - all_channel_hooks_declared_as_filter_type
+
+test_files:
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/RegisterNotificationChannelsListenerTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/SmsChannelDriverTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Listeners/GuestPhoneExtractListenerTest.php
+ - plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/ChannelRegistrationHookTest.php
+
+audit:
+ status: codified
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json
new file mode 100644
index 00000000..170e1528
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/tsconfig.json
@@ -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"]
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts
new file mode 100644
index 00000000..215bb6b0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/vite.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig } from 'vite';
+import path from 'path';
+
+/**
+ * 비즈뿌리오 메시징 플러그인 프론트엔드 에셋 빌드 설정.
+ *
+ * 플러그인 엔트리(resources/js/index.ts)를 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,
+ // 소스맵 생성
+ // 배포용 빌드(--production)는 G7_BUILD_SOURCEMAP=0 을 주입해 소스맵을 생성하지 않는다.
+ // 미설정(로컬 npm run build)이면 생성 — 개발 디버깅 경험을 유지한다.
+ sourcemap: !['0', 'false'].includes(process.env.G7_BUILD_SOURCEMAP ?? ''),
+ 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'),
+ },
+ },
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts
new file mode 100644
index 00000000..ae686dc2
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/vitest.config.ts
@@ -0,0 +1,17 @@
+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: ['./resources/js/__tests__/setup.ts'],
+ },
+ resolve: {
+ alias: {
+ '@': resolve(__dirname, 'resources/js'),
+ '@core': resolve(__dirname, '../../../resources/js/core'),
+ },
+ },
+});
diff --git a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap
index 88cf888a..d311e555 100644
--- a/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap
+++ b/resources/js/core/template-engine/__tests__/__snapshots__/BindingShape.routingParity.test.ts.snap
@@ -256,6 +256,7 @@ exports[`BindingShape 라우팅 diff 하네스 > 표현식/경로 판정: 구
"modules/_bundled/sirsoft-page/resources/layouts/admin/admin_page_list.json",
"plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/ckeditor5_uploads.json",
"plugins/_bundled/sirsoft-gdpr/resources/layouts/admin/gdpr_consent_log.json",
+ "plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json",
"templates/_bundled/sirsoft-admin_basic/layouts/_admin_base.json",
"templates/_bundled/sirsoft-admin_basic/layouts/admin_module_list.json",
"templates/_bundled/sirsoft-admin_basic/layouts/admin_plugin_list.json",
diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php
index bee0bff6..76663499 100644
--- a/tests/Unit/Notifications/GenericNotificationViaTest.php
+++ b/tests/Unit/Notifications/GenericNotificationViaTest.php
@@ -313,7 +313,7 @@ class GenericNotificationViaTest extends TestCase
/**
* 사용 가능한 채널 목록에 없는 채널(비활성 확장이 제공하던 채널)은 via() 가 제외한다.
*
- * 회귀: 메시징 확장이 비활성이면 그 확장의 채널(sms/alimtalk)이 getAvailableChannels()
+ * 회귀: 비즈뿌리오 등 확장이 비활성이면 그 확장의 채널(sms/alimtalk)이 getAvailableChannels()
* 에서 빠진다. 하지만 저장된 채널 설정·템플릿은 남아 있어, via() 가 available 목록을 확인하지
* 않으면 죽은 채널을 발송 후보로 넣고 "건너뜀" 로그까지 남긴다. available 목록에 없는 채널은
* 로그 없이 제외되어야 한다 (채널 지정 경로).
From e17731d8cc241077d4a8a2e0619753bc5486d84e Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Fri, 21 Aug 2026 12:45:31 +0900
Subject: [PATCH 3/7] =?UTF-8?q?feat(message=5Fbizppurio):=20=EC=95=8C?=
=?UTF-8?q?=EB=A6=BC=ED=86=A1=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EB=9D=BC?=
=?UTF-8?q?=EC=9D=B4=ED=94=84=EC=82=AC=EC=9D=B4=ED=81=B4=20=EA=B0=9C?=
=?UTF-8?q?=ED=8E=B8=20=E2=80=94=20DB=20=EA=B8=B0=EB=B0=98=20=EC=9E=91?=
=?UTF-8?q?=EC=84=B1=C2=B7=EA=B2=80=EC=88=98=C2=B7=EC=8A=B9=EC=9D=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
비즈뿌리오 알림톡 템플릿을 콘솔 위임(발송 전 실시간 조회) 방식에서 시스템 내
작성(draft) → 검수 신청(requested) → 승인(approved) 라이프사이클로 개편한다.
발송 판정의 SSoT 를 DB(bizppurio_templates)로 옮기고 발송 전 실시간 조회를 폐지.
- 알림↔템플릿 바인딩 모델(notification_bindings) 제거, 알림 1건당 템플릿 1행으로 대체
- 검수 신청: template_code 자체 채번(codeCheck 재시도) → kapi add/update → request
- 상태 동기화: 30분 스케줄러 + 수동 새로고침, 승인 전이 시 content 를 approved_content 로 동결
- SMS 본문 언어별 입력 복원(수신자 로케일 발송), SMS 단독 선택 시 알림톡 미발송
- 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭 + 플러그인 '알림 템플릿 관리' 화면
- 중복 검수 신청은 서버 원자 선점(claimForInspection 조건부 UPDATE)으로 차단
- admin_basic 1.0.6 / board 1.0.5 / ecommerce 1.1.2: 채널 서브탭 통합 탭 선언(tab_channels) 지원
- 검증: 계획서 대비 5라운드 전수검증, §6.3 실측 매트릭스 Playwright 19 고정,
PHPUnit 421 / Vitest 228 green, audit 0 error
---
AGENTS.md | 2 +-
.../CHANGELOG.md | 2 +-
.../backend/ja/messages.php | 107 +-
.../frontend/ja.json | 278 +-
modules/_bundled/sirsoft-board/CHANGELOG.md | 5 +
.../admin-board-bizppurio-tab-filter.test.ts | 158 +
.../_tab_notification_definitions.json | 4 +-
.../_bundled/sirsoft-ecommerce/CHANGELOG.md | 5 +
...min-ecommerce-bizppurio-tab-filter.test.ts | 158 +
.../_tab_notification_definitions.json | 4 +-
.../sirsoft-message_bizppurio/CHANGELOG.md | 4 +-
.../sirsoft-message_bizppurio/README.md | 70 +-
..._bizppurio_notification_bindings_table.php | 51 -
...00002_create_bizppurio_templates_table.php | 68 +
.../dist/js/plugin.iife.js | 2 +-
.../docs/api/README.md | 7 +-
.../docs/api/alimtalk-templates.md | 180 +-
.../docs/api/dispatch-results.md | 11 +-
.../docs/api/notification-bindings.md | 196 -
.../docs/api/report.md | 2 +-
.../docs/api/templates.md | 998 ++-
.../docs/api/webhook.md | 32 +-
.../lang/en/messages.php | 99 +-
.../lang/ko/messages.php | 99 +-
.../sirsoft-message_bizppurio/package.json | 4 +-
.../sirsoft-message_bizppurio/plugin.php | 36 +-
.../extensions/notification_row_footer.json | 861 +-
.../extensions/notification_tab_board.json | 2454 +++++-
.../extensions/notification_tab_core.json | 2454 +++++-
.../notification_tab_ecommerce.json | 2454 +++++-
.../handlers/uploadTemplateImage.test.ts | 161 +
.../layouts/alimtalk_templates.test.tsx | 309 -
.../resources/js/__tests__/layouts/helpers.ts | 19 +
.../layouts/notification_tab_binding.test.tsx | 238 -
.../notification_tab_board_ecommerce.test.tsx | 192 +-
.../notification_tab_lifecycle.test.tsx | 691 ++
.../layouts/plugin_settings.test.tsx | 37 +-
.../layouts/plugin_settings_manage.test.tsx | 373 +
.../template_request_reentrancy_guard.test.ts | 201 +
.../resources/js/handlers/index.ts | 14 +-
.../resources/js/handlers/insertVariable.ts | 117 +
.../js/handlers/uploadTemplateImage.ts | 292 +
.../resources/js/index.ts | 2 +-
.../resources/lang/en.json | 328 +-
.../resources/lang/ko.json | 262 +-
.../layouts/admin/plugin_settings.json | 6908 ++++++++++++-----
.../src/Concerns/GuardsKakaoRequests.php | 7 +-
.../src/Console/SyncTemplateStatusCommand.php | 60 +
.../Admin/AlimtalkTemplateController.php | 73 +-
.../Admin/BizppurioTemplateController.php | 380 +
.../Admin/NotificationBindingController.php | 99 -
.../src/Enums/BizppurioTemplateStatus.php | 110 +
.../BizppurioTemplateStateException.php | 49 +
.../Requests/AlimtalkTemplateListRequest.php | 58 -
.../BizppurioTemplateImageRequest.php | 91 +
.../Requests/BizppurioTemplateListRequest.php | 96 +
.../Concerns/ValidatesTemplateContent.php | 366 +
.../StoreBizppurioTemplateRequest.php | 84 +
.../StoreNotificationBindingRequest.php | 41 -
.../UpdateBizppurioDeliveryRequest.php | 89 +
.../UpdateBizppurioTemplateRequest.php | 73 +
.../RegisterNotificationChannelsListener.php | 19 +-
.../SeedChannelTemplatesListener.php | 7 +
.../Models/BizppurioNotificationBinding.php | 87 -
.../src/Models/BizppurioTemplate.php | 168 +
.../MessageBizppurioServiceProvider.php | 21 +-
...BizppurioNotificationBindingRepository.php | 79 -
.../BizppurioTemplateRepository.php | 240 +
...NotificationBindingRepositoryInterface.php | 53 -
.../BizppurioTemplateRepositoryInterface.php | 106 +
.../src/Services/AlimtalkChannelDriver.php | 130 +-
.../src/Services/AlimtalkPayloadMapper.php | 15 +
.../src/Services/AlimtalkTemplateService.php | 196 +-
.../src/Services/BizppurioKakaoApiClient.php | 192 +-
.../src/Services/BizppurioTemplateService.php | 719 ++
.../Services/KakaoTemplateContentResolver.php | 193 -
.../Services/NotificationBindingService.php | 239 -
.../src/Services/SmsChannelDriver.php | 70 +-
.../src/routes/api.php | 49 +-
.../AlimtalkTemplateControllerTest.php | 193 +-
.../Feature/Installation/InstallationTest.php | 26 +-
.../NotificationBindingEndpointTest.php | 251 -
.../BizppurioTemplateControllerTest.php | 1061 +++
.../SyncTemplateStatusCommandTest.php | 102 +
.../Playwright/fixtures/bizppurio-auth.ts | 43 +
.../tests/Playwright/playwright.config.ts | 82 +
.../admin/template-lifecycle-matrix.spec.ts | 745 ++
.../specs/admin/template-lifecycle.spec.ts | 163 +
.../Enums/BizppurioTemplateStatusTest.php | 175 +
...gisterNotificationChannelsListenerTest.php | 25 +
.../Unit/Models/BizppurioTemplateTest.php | 208 +
...purioNotificationBindingRepositoryTest.php | 67 -
.../Services/AlimtalkChannelDriverTest.php | 512 +-
.../Services/AlimtalkPayloadMapperTest.php | 72 +
.../Services/AlimtalkTemplateServiceTest.php | 149 +-
.../Services/BizppurioKakaoApiClientTest.php | 187 +
.../Services/BizppurioTemplateServiceTest.php | 736 ++
.../KakaoTemplateContentResolverTest.php | 174 -
.../NotificationBindingServiceTest.php | 316 -
.../Unit/Services/SmsChannelDriverTest.php | 405 +-
.../scenarios/alimtalk-dispatch-binding.yaml | 148 -
.../tests/scenarios/alimtalk-templates.yaml | 93 -
.../tests/scenarios/bizppurio-dispatch.yaml | 66 +
.../bizppurio-template-lifecycle.yaml | 131 +
.../scenarios/dispatch-history-webhook.yaml | 1 -
.../tests/scenarios/plugin-settings.yaml | 6 +-
.../tests/scenarios/sms-channel-dispatch.yaml | 7 +-
.../BindingShape.routingParity.test.ts.snap | 2 +-
.../_bundled/sirsoft-admin_basic/CHANGELOG.md | 4 +
...dmin-settings-bizppurio-tab-filter.test.ts | 261 +
.../_tab_notification_definitions.json | 4 +-
111 files changed, 24733 insertions(+), 6890 deletions(-)
create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-bizppurio-tab-filter.test.ts
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-bizppurio-tab-filter.test.ts
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_notification_bindings_table.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_templates_table.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/docs/api/notification-bindings.md
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/alimtalk_templates.test.tsx
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_binding.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Console/SyncTemplateStatusCommand.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/NotificationBindingController.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Enums/BizppurioTemplateStatus.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioTemplateStateException.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/AlimtalkTemplateListRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateImageRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateListRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/Concerns/ValidatesTemplateContent.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreBizppurioTemplateRequest.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreNotificationBindingRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioDeliveryRequest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioTemplateRequest.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioNotificationBinding.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioTemplate.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioNotificationBindingRepository.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioTemplateRepository.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioNotificationBindingRepositoryInterface.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioTemplateRepositoryInterface.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/BizppurioTemplateService.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/KakaoTemplateContentResolver.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/src/Services/NotificationBindingService.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Notification/NotificationBindingEndpointTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/BizppurioTemplateControllerTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Feature/Template/SyncTemplateStatusCommandTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/fixtures/bizppurio-auth.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/playwright.config.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Playwright/specs/admin/template-lifecycle.spec.ts
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Enums/BizppurioTemplateStatusTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Models/BizppurioTemplateTest.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Repositories/BizppurioNotificationBindingRepositoryTest.php
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/BizppurioTemplateServiceTest.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/KakaoTemplateContentResolverTest.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/Unit/Services/NotificationBindingServiceTest.php
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-dispatch-binding.yaml
delete mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/alimtalk-templates.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-dispatch.yaml
create mode 100644 plugins/_bundled/sirsoft-message_bizppurio/tests/scenarios/bizppurio-template-lifecycle.yaml
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-bizppurio-tab-filter.test.ts
diff --git a/AGENTS.md b/AGENTS.md
index 3b25388d..45fae97e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -168,7 +168,7 @@
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `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) | 7 / 13 |
+| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md
index 57ad5f67..1dc7a8a9 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md
@@ -8,4 +8,4 @@
### Added
-- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 관리·발송 이력 화면과 발송 결과 코드 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 요청 과다로 인한 일시적 발송 실패 사유, 미승인 템플릿 연결 시도 시 안내 문구도 포함됩니다.
+- 비즈뿌리오 메시징 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩을 제공합니다. 환경설정·알림톡 템플릿 작성/검수 신청/승인 관리·템플릿 통합 관리·발송 이력 화면과 발송 결과 안내가 일본어 로케일에서 자연스럽게 표시됩니다. 템플릿 작성 시 입력값 오류 안내도 항목 이름까지 일본어로 표시됩니다.
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php
index 417251d4..db1bf3d7 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php
@@ -6,6 +6,10 @@ return [
'lms' => 'LMS',
'alimtalk' => 'アラートトーク',
],
+ 'channel_group' => [
+ 'text' => '文字',
+ 'alimtalk' => '通知トーク',
+ ],
'status' => [
'pending' => '待機中',
'sent' => '送信中',
@@ -26,11 +30,11 @@ return [
'channels' => [
'source_label' => 'ビズプリオ',
'sms' => [
- 'name' => 'SMS/LMSテキスト',
+ 'name' => 'ビズプリオ 文字',
'description' => 'ビズプリオを通じてテキスト(SMS/LMS)で通知を送信します。',
],
'alimtalk' => [
- 'name' => 'カカオアラートトーク',
+ 'name' => 'ビズプリオ アラートトーク',
'description' => 'ビズプリオを通じてカカオアラートトークで通知を送信します。',
],
],
@@ -51,33 +55,102 @@ return [
'error' => [
'credentials_missing' => 'ビズプリオのアイディとパスワードを先に設定してください。',
'token_issue_failed' => 'ビズプリオ認証トークン発行に失敗しました。',
+ 'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
'send_failed' => 'メッセージ送信リクエストに失敗しました。',
'send_retryable' => 'メッセージ送信が一時的に失敗しました。(コード: :code)',
'invalid_response' => 'ビズプリオレスポンスを解析できません。',
+ 'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
'kakao_credentials_missing' => 'カカオ管理API 使用のためにアイディとAPI キーを先に設定してください。',
'kakao_request_failed' => 'カカオ管理API リクエストに失敗しました。',
'sender_key_missing' => 'アラートトーク発信プロフィールキーを先に設定してください。',
- 'template_not_sendable' => '送信可能(承認)ステータスではないテンプレートです。(コード: :code)',
- 'token_issue_failed_with_reason' => 'ビズプリオ認証トークンの発行に失敗しました。(:reason)',
- 'connection_failed' => 'ビズプリオ サーバーに接続できません。しばらく後にもう一度お試しください。',
+ 'image_unreadable' => 'アップロードした画像ファイルを読み込めません。',
],
'send_skipped' => [
- 'alimtalk_binding_missing' => 'アラートトークテンプレートが接続されていないため送信をスキップしました。(通知タイプ: :type)',
- 'alimtalk_kakao_content_unavailable' => 'カカオ承認テンプレート内容を照会できないため送信をスキップしました。(通知タイプ: :type)',
- 'sms_template_missing' => 'SMSテンプレートがないため送信をスキップしました。(通知タイプ: :type)',
+ 'alimtalk_template_not_approved' => '承認されたアラートトークテンプレートがないか、アラートトーク送信がオフになっているため送信をスキップしました。(通知タイプ: :type)',
+ 'sms_template_missing' => 'SMS本文が設定されていないため送信をスキップしました。(通知タイプ: :type)',
'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
],
- 'binding' => [
- 'saved' => 'アラートトーク連携を保存しました。',
- 'removed' => 'アラートトーク連携を解除しました。',
+ 'template' => [
+ 'created' => '通知テンプレートを保存しました。',
+ 'updated' => '通知テンプレートを更新しました。',
+ 'requested' => '審査を申請しました。承認結果は自動的に同期され、[更新]ですぐに確認できます。',
+ 'request_cancelled' => '審査申請を取り消しました。',
+ 'approval_cancelled' => '承認を取り消しました。この通知のアラートトーク送信が停止されました。',
+ 'released' => '休眠状態を解除しました。',
+ 'synced' => 'カカオ審査ステータスを同期しました。',
+ 'deleted' => '通知テンプレートを削除しました。',
+ 'image_uploaded' => '画像をアップロードしました。',
+ 'content_locked' => '現在のステータス(:status)ではアラートトークの内容を修正できません。審査中の場合は申請の取り消しを、承認済みの場合は承認の取り消しを先に行ってください。',
+ 'content_missing' => 'アラートトークテンプレートの内容を先に作成してください。',
+ 'request_not_allowed' => '現在のステータス(:status)では審査を申請できません。',
+ 'cancel_request_not_allowed' => '審査中ステータスではないため、申請を取り消せません。(現在: :status)',
+ 'cancel_approval_not_allowed' => '承認ステータスではないため、承認を取り消せません。(現在: :status)',
+ 'release_not_allowed' => '休眠ステータスではないため、解除できません。(現在: :status)',
+ 'code_generation_failed' => 'テンプレートコードを採番できませんでした。しばらくしてからもう一度お試しください。',
],
- 'cache' => [
- 'cleared' => 'アラートトークテンプレート内容キャッシュを初期化しました。次回送信から最新内容が反映されます。',
- ],
- 'channel_group' => [
- 'text' => '文字',
- 'alimtalk' => '通知トーク',
+ 'validation' => [
+ 'link_mo_required' => 'ウェブリンク(WL)ボタンにはモバイルリンクが必要です。',
+ 'link_and_required' => 'アプリリンク(AL)ボタンにはAndroidスキームが必要です。',
+ 'link_ios_required' => 'アプリリンク(AL)ボタンにはiOSスキームが必要です。',
+ 'tel_number_required' => '電話(TN)ボタンには電話番号が必要です。',
+ 'plugin_id_required' => 'プラグイン(P1~P3)ボタンにはプラグインIDが必要です。',
+ 'highlight_title_too_long' => 'アイテムハイライトのタイトルは:max文字以内である必要があります。',
+ 'highlight_description_too_long' => 'アイテムハイライトの説明は:max文字以内である必要があります。',
+ 'image_ratio_invalid' => '画像は横:縦 2:1 の比率である必要があります。(例: 1000×500px)',
+
+ // 検証エラー文に使うフィールドラベル (FormRequest::attributes)
+ 'attributes' => [
+ 'notification_type' => '通知タイプ',
+ 'content' => 'アラートトーク内容',
+ 'template_name' => 'テンプレート名',
+ 'message_type' => 'メッセージタイプ',
+ 'emphasize_type' => '強調タイプ',
+ 'template_content' => '本文',
+ 'preview_message' => 'プレビュー文言',
+ 'category_code' => 'カテゴリ',
+ 'security_flag' => 'セキュリティテンプレート',
+ 'extra' => '付加情報',
+ 'title' => '強調タイトル',
+ 'subtitle' => '強調サブタイトル',
+ 'header' => 'ヘッダー',
+ 'image_name' => '画像ファイル名',
+ 'image_url' => '画像URL',
+ 'item' => 'アイテムリスト',
+ 'item_list' => 'アイテム一覧',
+ 'item_title' => 'アイテム名',
+ 'item_description' => 'アイテム説明',
+ 'summary' => '要約',
+ 'summary_title' => '要約タイトル',
+ 'summary_description' => '要約説明',
+ 'highlight' => 'ハイライト',
+ 'highlight_title' => 'ハイライトタイトル',
+ 'highlight_description' => 'ハイライト説明',
+ 'highlight_image_url' => 'ハイライト画像URL',
+ 'represent_link' => '代表リンク',
+ 'buttons' => 'ボタン',
+ 'button_name' => 'ボタン名',
+ 'button_link_type' => 'ボタンリンクタイプ',
+ 'quick_replies' => 'クイックリプライ',
+ 'quick_reply_name' => 'クイックリプライ名',
+ 'quick_reply_link_type' => 'クイックリプライリンクタイプ',
+ 'link_mo' => 'モバイルリンク',
+ 'link_pc' => 'PCリンク',
+ 'link_and' => 'Androidスキーム',
+ 'link_ios' => 'iOSスキーム',
+ 'tel_number' => '電話番号',
+ 'plugin_id' => 'プラグインID',
+ 'image' => '画像ファイル',
+ 'sms_body' => 'SMS本文',
+ 'alimtalk_enabled' => 'アラートトーク利用',
+ 'fallback_sms_enabled' => '代替SMS利用',
+ 'sms_only' => 'SMS単独送信',
+ 'is_active' => '有効',
+ 'status' => 'ステータス',
+ 'search' => '検索キーワード',
+ 'page' => 'ページ',
+ 'per_page' => '1ページあたりの件数',
+ ],
],
'token_check' => [
'success' => '認証が正常に確認されました。ユーザーIDとパスワードが正しいです。',
diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json
index 25e709b3..897c15aa 100644
--- a/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json
@@ -34,6 +34,15 @@
"label": "ビズプリオモジュールパスワード",
"hint": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
},
+ "connection_check": {
+ "label": "接続確認",
+ "hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
+ "button": "接続確認",
+ "checking": "確認中...",
+ "success": "認証が正常に確認されました。IDとパスワードが正しいです。",
+ "failed": "認証確認に失敗しました。",
+ "unsaved_changes": "変更内容を先に保存してください。"
+ },
"api_key": {
"label": "API キー",
"hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
@@ -45,23 +54,6 @@
"sender_key": {
"label": "アラート톡 発信プロファイル キー",
"hint": "アラート톡 発送・テンプレート確認に使用する発信プロファイル キー(40文字)です。"
- },
- "template_cache_minutes": {
- "label": "アラート톡 内容 キャッシュ 時間(分)",
- "hint": "カカオ テンプレート内容をこの時間だけ再利用します。0 = 毎回最新(発送が多い場合は非推奨)。",
- "clear_cache": "キャッシュ 初期化",
- "clear_cache_hint": "カカオでテンプレートを修正した場合は押して即座に反映してください。",
- "clear_cache_success": "テンプレート内容 キャッシュを初期化しました。次の発送から最新内容が反映されます。",
- "clear_cache_failed": "キャッシュ 初期化に失敗しました。"
- },
- "connection_check": {
- "label": "接続確認",
- "hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
- "button": "接続確認",
- "checking": "確認中...",
- "success": "認証が正常に確認されました。IDとパスワードが正しいです。",
- "failed": "認証確認に失敗しました。",
- "unsaved_changes": "変更内容を先に保存してください。"
}
},
"report": {
@@ -71,13 +63,9 @@
"copy": "コピー",
"copied": "レポート受信アドレスがクリップボードにコピーされました。"
},
- "cache": {
- "section_title": "アラート톡 発送内容 キャッシュ",
- "section_description": "アラート톡は発送する際、カカオに登録されたテンプレート内容(本文・ボタン)をそのまま送信する必要があります。発送するたびにカカオに内容をリクエストしないよう、一度取得した内容を一定時間再利用(キャッシュ)します。これにより、発送が多くてもカカオ確認の制限に引っかからず、素早く発送されます。"
- },
"tabs": {
"connection": "環境設定",
- "templates": "アラート톡 テンプレート"
+ "templates": "通知テンプレート管理"
},
"preparation": {
"intro": "文字・カカオ アラート톡を発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
@@ -85,31 +73,16 @@
"sms_sender": "発信番号を登録してください。",
"kakao_label": "カカオ アラート톡",
"kakao_channel": "카카오톡 ビジネスチャネルを作成し、発信プロファイルを登録してください。",
- "kakao_template": "アラート톡 テンプレートを登録して承認を受けてください。",
+ "kakao_template": "アラームトークテンプレートは管理者の通知設定(ビズプリオタブ)で作成して審査を申請します。",
"kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
"console_link": "ビズプリオ コンソールを開く"
}
},
+ "channels": {
+ "bizppurio_tab": "ビズプリオ"
+ },
"binding": {
- "section_title": "カカオ アラート톡 連動",
- "list_guide": "各 通知に発送するカカオ アラート톡 テンプレートを接続してください。[接続]で承認されたテンプレートを指定すると、該当イベント発生時にアラート톡が自動発送されます。",
- "section_hint": "承認されたアラート톡 テンプレートのみ接続できます。保存するとこの 通知に即座に反映されます。",
- "modal_title": "アラート톡 接続 · {name}",
- "unbound": "未接続",
- "unavailable": "使用不可 — 再接続が必要",
- "btn_connect": "接続",
- "btn_change": "接続を変更",
- "fallback_on": "SMS 代替 ON",
- "fallback_off": "SMS 代替 OFF",
- "connected_template": "接続テンプレート",
- "none": "接続しない",
- "no_approved_templates": "発送可能な(承認された)アラート톡 テンプレートがありません。まずテンプレートを登録・検査してください。",
- "templates_load_failed": "テンプレート リストを読み込みできませんでした。プラグイン 設定(認証情報)を確認してください。",
- "fallback_sms": "失敗時 SMS で代替発送",
- "fallback_hint": "アラート톡 発送が失敗すると、この 通知の本文内容が文字(SMS)で代わりに発送されます。",
- "variables_hint": "提供変数(発送時に自動置換)",
- "saved": "アラート톡 連動を保存しました。",
- "save_error": "アラート톡 連動 保存に失敗しました。"
+ "variables_hint": "提供変数(クリックすると本文のカーソル位置に挿入されます)"
},
"banner": {
"not_ready": "発送に必要な 設定が完了していません。",
@@ -124,70 +97,174 @@
"detail_title": "文字・アラート톡 発送 結果",
"channel_label": "発送チャネル: {channel}",
"sent_content_label": "実際の発送内容",
- "sent_content_hint": "アラート톡 はカカオ承認テンプレートの実際の内容で発送され、上記の\"本文\"と異なる場合があります。"
+ "sent_content_hint": "アラート톡 は承認確定されたテンプレート内容で発送され、上記の\"本文\"と異なる場合があります。"
},
"editor": {
"data_source": {
"dispatch_results": "ビズプリオ送信結果"
}
},
+ "template": {
+ "tab_guide": "通知ごとにアラームトークテンプレートを作成して審査を申請してください。カカオ承認後にアラームトークが自動送信され、代替SMS・SMS単独送信もこの画面で設定します。",
+ "status": {
+ "unwritten": "未作成",
+ "draft": "作成中",
+ "requested": "審査中",
+ "approved": "承認済み",
+ "rejected": "却下",
+ "stopped": "停止",
+ "blocked": "ブロック",
+ "dormant": "休眠"
+ },
+ "row": {
+ "alimtalk_label": "アラームトーク",
+ "requested_on": "{date} 申請",
+ "synced_on": "{date} 同期",
+ "alimtalk_enabled": "送信を使用",
+ "btn_view_reason": "理由を表示",
+ "btn_compose": "アラームトークテンプレート作成",
+ "btn_edit": "編集",
+ "btn_edit_approved": "編集 (承認取り消し)",
+ "btn_request": "審査申請",
+ "btn_cancel_request": "申請取り消し",
+ "btn_release": "休眠解除",
+ "btn_refresh": "更新",
+ "requested_toast": "審査を申請しました。",
+ "cancel_request_toast": "審査申請を取り消しました。",
+ "released_toast": "休眠状態を解除しました。",
+ "synced_toast": "カカオ審査ステータスを同期しました。",
+ "fallback_sms": "代替SMS",
+ "sms_only": "SMS単独",
+ "sms_body_prefix": "本文:",
+ "sms_body_missing": "SMS本文未設定",
+ "btn_edit_sms": "SMS本文を編集"
+ },
+ "form": {
+ "modal_title": "アラームトークテンプレート — {name}",
+ "rejected_banner": "却下されたテンプレートです。以下の理由を確認し、修正後に再度申請してください。",
+ "sender_profile": "発信プロフィール",
+ "sender_profile_placeholder": "発信プロフィール選択(参考用)",
+ "sender_profile_hint": "審査申請は環境設定に保存された発信プロフィールキーで行われます。",
+ "category": "カテゴリ",
+ "category_placeholder": "カテゴリ選択",
+ "name": "テンプレート名",
+ "message_type": "メッセージ種別",
+ "templateMessageType_options": {
+ "BA": "基本型",
+ "EX": "付加情報型",
+ "AD": "チャネル追加型",
+ "MI": "複合型"
+ },
+ "emphasize_type": "強調種別",
+ "templateEmphasizeType_options": {
+ "NONE": "なし",
+ "TEXT": "強調表記",
+ "IMAGE": "画像",
+ "ITEM_LIST": "アイテムリスト"
+ },
+ "emphasize_title": "強調表記タイトル",
+ "emphasize_subtitle": "強調表記サブタイトル",
+ "image": "画像",
+ "image_hint": "jpg/png · 500KB以下 · 横500px以上 · 横:縦 2:1 (アップロードするとURLが自動入力されます)",
+ "image_uploading": "画像をアップロード中...",
+ "image_upload_failed": "画像のアップロードに失敗しました。",
+ "content": "本文",
+ "extra": "付加情報",
+ "header": "ヘッダー",
+ "header_hint": "本文上部に表示される最大16文字の文言(任意)",
+ "buttons": "ボタン (最大5個)",
+ "button_add": "ボタン追加",
+ "button_name": "ボタン名 (最大14文字)",
+ "quick_replies": "クイックリンク (最大10個)",
+ "quick_reply_add": "クイックリンク追加",
+ "link_mo": "モバイルリンク (必須)",
+ "link_pc": "PCリンク (任意)",
+ "link_and": "Androidスキーム (必須)",
+ "link_ios": "iOSスキーム (必須)",
+ "tel_number": "電話番号 (必須)",
+ "plugin_id": "プラグインID (必須)",
+ "item_highlight": "アイテムハイライト (任意)",
+ "hl_title": "ハイライトタイトル",
+ "hl_description": "ハイライト説明",
+ "hl_image_url": "サムネイル画像URL (任意)",
+ "item_list": "アイテム一覧 (2~10個)",
+ "item_list_hint": "アイテムは最小2個、最大10個です。(タイトル6文字・説明23文字以内)",
+ "item_add": "アイテム追加",
+ "item_title": "タイトル",
+ "item_description": "説明",
+ "item_summary": "要約(価格情報)",
+ "summary_description": "要約値 (14文字以内)",
+ "btn_remove_item": "アイテム削除",
+ "btn_remove_button": "ボタン削除",
+ "btn_remove_quick_reply": "クイックリプライ削除",
+ "btn_save": "保存",
+ "btn_save_request": "保存して審査申請",
+ "saved_toast": "通知テンプレートを保存しました。",
+ "requested_toast": "審査を申請しました。承認されるとアラームトーク送信が開始されます。"
+ },
+ "sms": {
+ "modal_title": "SMS本文 — {name}",
+ "hint": "代替SMSとSMS単独送信に共通で使用する本文です。#{변수} は送信時に自動置換されます。",
+ "btn_save": "保存",
+ "saved_toast": "SMS本文を保存しました。"
+ },
+ "cancel_approval": {
+ "modal_title": "承認取り消しの確認",
+ "warning": "「{name}」テンプレートの承認を取り消すと、直ちにこの通知のアラームトーク送信が停止されます。(代替SMS・SMS単独送信はSMS設定に従って継続されます。)修正後に再度審査を申請して承認を受けると、アラームトーク送信が再開されます。",
+ "btn_confirm": "承認を取り消して編集",
+ "done_toast": "承認を取り消しました。修正後に再度審査を申請してください。"
+ },
+ "rejection": {
+ "modal_title": "却下理由 — {name}",
+ "btn_close": "閉じる"
+ }
+ },
+ "manage": {
+ "guide": "すべての通知のビズプリオテンプレート状態を一目で管理します。作成・審査申請は通知設定画面と同じように動作します。",
+ "filter_all": "すべてのステータス",
+ "search_placeholder": "通知名/タイプ検索",
+ "btn_search": "検索",
+ "total_count": "合計 {count}件",
+ "btn_compose": "作成",
+ "btn_edit": "編集",
+ "btn_delete": "削除",
+ "empty": "登録されたビズプリオ通知テンプレートがありません。",
+ "empty_hint": "管理者の通知設定のビズプリオタブでアラームトークテンプレートを作成するか、SMSを設定するとここに表示されます。",
+ "sms_only": "単独",
+ "sms_fallback": "代替",
+ "owner": {
+ "core": "コア",
+ "module": "モジュール",
+ "plugin": "プラグイン"
+ },
+ "columns": {
+ "notification": "通知",
+ "owner": "所属",
+ "status": "アラームトークステータス",
+ "sms": "SMS",
+ "requested_at": "申請日",
+ "synced_at": "同期",
+ "actions": "管理"
+ },
+ "delete": {
+ "modal_title": "テンプレート削除",
+ "warning": "「{name}」のビズプリオテンプレート設定を削除します。カカオに登録されたテンプレートは削除可能なステータス(登録/却下)の場合のみ一緒に削除されます。",
+ "btn_confirm": "削除",
+ "done_with_kakao": "テンプレートを削除しました。(カカオ側テンプレートを含む)",
+ "done_db_only": "テンプレート設定を削除しました。カカオ側テンプレートはステータス制約により残っています。"
+ }
+ },
"templates": {
- "title": "アラームトークテンプレート",
- "description": "カカオアラームトークテンプレートを登録·審査·管理します。承認されたテンプレートのみアラーム連動に使用できます。",
"readiness": {
"title": "アラームトークテンプレートを使用するには、以下の設定が必要です",
"go_settings": "環境設定に移動",
"missing_label": "未設定項目",
"api_key_missing": "カカオ管理API キー",
"sender_key_missing": "アラームトーク送信プロフィールキー",
- "note": "上記項目を環境設定タブで入力すると、テンプレート照会·登録が可能になります。実際の送信は審査モードをオフにして運用に切り替えた後、カカオ承認を受けたテンプレートのみ使用できます。"
- },
- "list_error": {
- "title": "テンプレート一覧を読み込めませんでした"
- },
- "list_notice": {
- "console_desc": "テンプレートの登録·編集·審査はビズプリオコンソールで進めてください。",
- "console_link": "ビズプリオコンソールを開く"
+ "note": "上記項目を環境設定タブで入力すると、テンプレートの作成・審査申請が可能になります。実際の送信は検査モードをオフにして運用に切り替えた後、承認されたテンプレートのみ使用できます。"
},
"list": {
- "refresh": "更新",
- "search": "検索",
- "search_placeholder": "テンプレート名検索(2~50文字)",
- "filter_all": "すべてのステータス",
- "empty": "登録されたアラームトークテンプレートがありません。",
- "empty_hint": "ビズプリオコンソールで登録したテンプレートがここに表示されます。",
- "load_failed": "テンプレート一覧を読み込めませんでした。送信プロフィールキーとAPI キーを確認してください。",
- "columns": {
- "no": "番号",
- "name": "テンプレート名",
- "code": "コード",
- "status": "ステータス",
- "requested_at": "登録申請日",
- "processed_at": "処理日",
- "actions": "内容"
- }
- },
- "status": {
- "sendable": "送信可能",
- "inspecting": "審査中",
- "rejected": "却下",
- "uninspected": "未審査",
- "stopped": "停止",
- "blocked": "ブロック",
- "dormant": "休止",
- "unknown": "不明"
- },
- "status_sub": {
- "rdy": "(使用前)"
- },
- "status_guide": {
- "title": "ステータスバッジ",
- "sendable_label": "送信可能",
- "sendable": "承認完了。このステータスのみアラームに接続·送信できます。",
- "inspecting_label": "審査中",
- "inspecting": "カカオ審査進行中(営業日2~3日)。",
- "pending_label": "未審査·却下",
- "pending": "まだ送信できません。コンソールで審査申請·修正してください。"
+ "refresh": "更新"
},
"link_type": {
"WL": "ウェブリンク",
@@ -203,25 +280,6 @@
"P1": "画像セキュア送信",
"P2": "個人情報利用",
"P3": "ワンクリック決済"
- },
- "actions": {
- "detail": "詳細"
- },
- "detail": {
- "title": "テンプレート詳細",
- "close": "閉じる",
- "buttons": "ボタン",
- "extra": "付加情報",
- "category": "カテゴリ",
- "code": "テンプレートコード",
- "content": "テンプレート内容",
- "emphasize_type": "テンプレート種別",
- "image_upload": "画像添付",
- "subtitle_field": "補助文句",
- "title_field": "強調表記文句",
- "type_image": "画像形",
- "type_none": "基本形",
- "type_text": "強調表記形"
}
}
}
diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md
index 0d390800..c44dd02b 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -10,6 +10,11 @@
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
+- 게시판 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시)
+
+### Changed
+
+- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
## [1.0.5] - 2026-08-22
diff --git a/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-bizppurio-tab-filter.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-bizppurio-tab-filter.test.ts
new file mode 100644
index 00000000..88d04429
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-bizppurio-tab-filter.test.ts
@@ -0,0 +1,158 @@
+// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
+/**
+ * 게시판 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
+ *
+ * 이 면의 서브탭 필터는 admin_basic·이커머스와 같은 식을 공유한다. 그 동일성은
+ * admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
+ * 러너에서만** 수집된다 — 게시판 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
+ * 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
+ *
+ * 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
+ * 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
+ *
+ * @scenario resource=notification_definitions_tab,endpoint=admin_board_settings,observation=tab_filter
+ *
+ * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { describe, it, expect } from 'vitest';
+
+const TAB_LAYOUT = path.resolve(
+ __dirname,
+ '../../../layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json',
+);
+
+/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
+const CHANNELS = [
+ { id: 'mail', source: 'core', name: '메일' },
+ { id: 'database', source: 'core', name: '사이트내 알림' },
+ { id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
+ {
+ id: 'alimtalk',
+ source: 'sirsoft-message_bizppurio',
+ name: '비즈뿌리오 알림톡',
+ tab_channels: ['sms', 'alimtalk'],
+ tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
+ },
+];
+
+/**
+ * id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
+ *
+ * @param node 탐색할 JSON 노드
+ * @returns 서브탭 컨테이너 노드 (없으면 null)
+ */
+function findSubTabsContainer(node: unknown): Record | null {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = findSubTabsContainer(child);
+ if (found) return found;
+ }
+ return null;
+ }
+ if (!node || typeof node !== 'object') return null;
+
+ const record = node as Record;
+ if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
+
+ for (const value of Object.values(record)) {
+ const found = findSubTabsContainer(value);
+ if (found) return found;
+ }
+ return null;
+}
+
+/**
+ * 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
+ *
+ * @param node 탐색할 JSON 노드
+ * @returns iteration 보유 노드 (없으면 null)
+ */
+function findIterationNode(node: unknown): Record | null {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = findIterationNode(child);
+ if (found) return found;
+ }
+ return null;
+ }
+ if (!node || typeof node !== 'object') return null;
+
+ const record = node as Record;
+ if (record.iteration && typeof record.iteration.source === 'string') return record;
+
+ for (const value of Object.values(record)) {
+ const found = findIterationNode(value);
+ if (found) return found;
+ }
+ return null;
+}
+
+describe('게시판 알림 설정 — 채널 서브탭 필터 실평가 (#597)', () => {
+ const layout = JSON.parse(fs.readFileSync(TAB_LAYOUT, 'utf-8'));
+ const container = findSubTabsContainer(layout);
+ const tabNode = container ? findIterationNode(container) : null;
+ const expr = String(tabNode?.iteration?.source ?? '').replace(/^\{\{|\}\}$/g, '');
+
+ /**
+ * 필터 표현식 원문을 실행해 노출 탭 id 목록을 반환합니다.
+ *
+ * @param saved _local.form.notifications.channels 저장값
+ * @param channels availableChannels 채널 목록
+ * @returns 필터를 통과한 채널 id 배열
+ */
+ function visibleTabs(
+ saved: Array<{ id: string; is_active: boolean }>,
+ channels: Array> = CHANNELS,
+ ): string[] {
+ // eslint-disable-next-line no-new-func
+ const fn = new Function('availableChannels', '_local', `return ${expr};`);
+ const result = fn(
+ { data: { channels } },
+ { form: { notifications: { channels: saved } } },
+ ) as Array<{ id: string }>;
+ return result.map((c) => c.id);
+ }
+
+ it('서브탭 필터 표현식을 레이아웃에서 추출할 수 있다', () => {
+ expect(container, '*channel_sub_tabs 컨테이너를 찾지 못했다').toBeTruthy();
+ expect(expr, '탭 iteration source 가 비어 있다').not.toBe('');
+ });
+
+ it('sms·alimtalk 모두 활성 → 통합 탭 1개만 노출, sms 개별 탭은 숨김', () => {
+ expect(visibleTabs([
+ { id: 'sms', is_active: true },
+ { id: 'alimtalk', is_active: true },
+ ])).toEqual(['mail', 'database', 'alimtalk']);
+ });
+
+ it('sms 만 활성(alimtalk 비활성) → tab_channels 규칙으로 통합 탭 노출', () => {
+ expect(visibleTabs([
+ { id: 'sms', is_active: true },
+ { id: 'alimtalk', is_active: false },
+ ])).toEqual(['mail', 'database', 'alimtalk']);
+ });
+
+ it('sms·alimtalk 모두 비활성 → 통합 탭 미노출', () => {
+ expect(visibleTabs([
+ { id: 'sms', is_active: false },
+ { id: 'alimtalk', is_active: false },
+ ])).toEqual(['mail', 'database']);
+ });
+
+ it('확장 채널 저장값 자체가 없으면(미저장=opt-in 전) 통합 탭 미노출', () => {
+ expect(visibleTabs([])).toEqual(['mail', 'database']);
+ });
+
+ it('코어 채널은 저장값이 명시적 false 일 때만 숨는다 (기본 노출)', () => {
+ expect(visibleTabs([{ id: 'mail', is_active: false }])).toEqual(['database']);
+ });
+
+ it('tab_channels 미선언 확장 채널은 자기 자신의 활성 저장 기준으로 판정된다', () => {
+ const withPlain = [...CHANNELS, { id: 'push', source: 'some-plugin', name: '푸시' }];
+ expect(visibleTabs([{ id: 'push', is_active: true }], withPlain)).toEqual(['mail', 'database', 'push']);
+ expect(visibleTabs([{ id: 'push', is_active: false }], withPlain)).toEqual(['mail', 'database']);
+ });
+});
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json
index 952537ec..c4dba2a8 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_notification_definitions.json
@@ -140,7 +140,7 @@
"type": "basic",
"name": "Button",
"iteration": {
- "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active === true)}}",
+ "source": "{{(availableChannels?.data?.channels ?? []).filter(c => c.hidden_tab !== true && (c.source === 'core' ? (_local.form?.notifications?.channels ?? []).find(nc => nc.id === c.id)?.is_active !== false : (c.tab_channels ?? [c.id]).filter(tc => (_local.form?.notifications?.channels ?? []).find(nc => nc.id === tc)?.is_active === true).length > 0))}}",
"item_var": "ch",
"index_var": "chIdx"
},
@@ -160,7 +160,7 @@
{
"type": "basic",
"name": "Span",
- "text": "{{ch.name ?? ch.id}}"
+ "text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
}
],
"actions": [
diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
index 932a7524..639b1aa7 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -9,6 +9,11 @@
### Added
- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
+- 이커머스 알림 설정 화면에서 비즈뿌리오 알림톡 템플릿을 작성해 검수를 신청하고, 카카오 승인 후 알림톡이 자동 발송됩니다. 대체 SMS·SMS 단독 발송도 같은 화면에서 설정합니다. (비즈뿌리오 메시지 발송 플러그인 설치 시)
+
+### Changed
+
+- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
### Fixed
diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-bizppurio-tab-filter.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-bizppurio-tab-filter.test.ts
new file mode 100644
index 00000000..5184c250
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-bizppurio-tab-filter.test.ts
@@ -0,0 +1,158 @@
+// e2e:allow 레이아웃 조건식 실평가 전용 — 브라우저 흐름은 비즈뿌리오 플러그인 spec 이 담당한다.
+/**
+ * 이커머스 알림 설정 — 채널 서브탭 필터 조합 실평가 (#597 §14.2 T8)
+ *
+ * 이 면의 서브탭 필터는 admin_basic·게시판와 같은 식을 공유한다. 그 동일성은
+ * admin_basic 쪽 패리티 테스트가 고정하지만, 그 테스트는 **admin_basic 템플릿의
+ * 러너에서만** 수집된다 — 이커머스 모듈만 단독으로 Vitest 를 돌리면 이 면의 필터는
+ * 한 번도 평가되지 않는다. 그래서 각 면이 자기 파일을 자기 러너에서 평가한다.
+ *
+ * 검증 대상: 확장이 선언한 통합 탭 메타(hidden_tab / tab_channels)를 필터가 실제로
+ * 해석하는가. 문자열 동일성 단언은 `!==` 를 `===` 로 바꾸는 오타를 잡지 못한다.
+ *
+ * @scenario resource=notification_definitions_tab,endpoint=admin_ecommerce_settings,observation=tab_filter
+ *
+ * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, tab_visible_when_any_of_tab_channels_active
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { describe, it, expect } from 'vitest';
+
+const TAB_LAYOUT = path.resolve(
+ __dirname,
+ '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json',
+);
+
+/** availableChannels 응답 형태 (코어 2 + 비즈뿌리오 2) */
+const CHANNELS = [
+ { id: 'mail', source: 'core', name: '메일' },
+ { id: 'database', source: 'core', name: '사이트내 알림' },
+ { id: 'sms', source: 'sirsoft-message_bizppurio', name: '비즈뿌리오 문자', hidden_tab: true },
+ {
+ id: 'alimtalk',
+ source: 'sirsoft-message_bizppurio',
+ name: '비즈뿌리오 알림톡',
+ tab_channels: ['sms', 'alimtalk'],
+ tab_label_key: 'sirsoft-message_bizppurio.channels.bizppurio_tab',
+ },
+];
+
+/**
+ * id 가 *channel_sub_tabs 로 끝나는 서브탭 컨테이너를 찾습니다.
+ *
+ * @param node 탐색할 JSON 노드
+ * @returns 서브탭 컨테이너 노드 (없으면 null)
+ */
+function findSubTabsContainer(node: unknown): Record | null {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = findSubTabsContainer(child);
+ if (found) return found;
+ }
+ return null;
+ }
+ if (!node || typeof node !== 'object') return null;
+
+ const record = node as Record;
+ if (typeof record.id === 'string' && record.id.endsWith('channel_sub_tabs')) return record;
+
+ for (const value of Object.values(record)) {
+ const found = findSubTabsContainer(value);
+ if (found) return found;
+ }
+ return null;
+}
+
+/**
+ * 서브탭 컨테이너 안에서 iteration 을 가진 탭 버튼 노드를 찾습니다.
+ *
+ * @param node 탐색할 JSON 노드
+ * @returns iteration 보유 노드 (없으면 null)
+ */
+function findIterationNode(node: unknown): Record