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/AGENTS.md b/AGENTS.md
index ad5912e2..45fae97e 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) | 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/CHANGELOG.md b/CHANGELOG.md
index 04df1e86..0ad31cc4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,21 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.9] - 2026-08-24
+
+### Added
+
+- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
+- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
+
+### Fixed
+
+- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
+- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
+- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
+- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
+- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
+
## [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/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php
index 2b39ecce..60614db2 100644
--- a/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php
+++ b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php
@@ -112,11 +112,13 @@ class RunSeedersOnLanguagePackLifecycle
*/
private function refreshRegistry(): void
{
- // Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget
- $this->app->forgetInstance(LanguagePackRegistry::class);
- $this->app->forgetInstance(LanguagePackSeedInjector::class);
-
+ // boot 시점에 등록된 seed 필터 클로저들은 injector→registry "인스턴스"를 캡처하고 있다.
+ // forgetInstance 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시(활성 팩 목록)가
+ // 그대로 남아, 같은 프로세스에서 활성화된 신규 팩이 코어 시더의 번역 필터에 보이지
+ // 않는다 (오류 없이 해당 로케일만 미주입). 싱글톤을 유지한 채 내부 캐시만 무효화해
+ // 캡처된 참조에도 전파한다 (#597 에서 실측·교정).
$registry = $this->app->make(LanguagePackRegistry::class);
+ $registry->invalidate();
config([
'app.supported_locales' => $registry->getActiveCoreLocales(),
'app.locale_names' => $registry->getLocaleNames(),
diff --git a/app/Listeners/LanguagePack/SyncDatabaseTranslations.php b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php
index 2ec5bd7c..ef880a55 100644
--- a/app/Listeners/LanguagePack/SyncDatabaseTranslations.php
+++ b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php
@@ -9,17 +9,19 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
- * `core.language_packs.after_activate` / `after_deactivate` 액션 훅을 받아 DB JSON 다국어
+ * `core.language_packs.activated` / `deactivated` 액션 훅을 받아 DB JSON 다국어
* 컬럼을 동기화하는 thin Listener (G7 표준 HookManager::doAction → addAction 메커니즘).
*
* 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일
* 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다.
*
- * 정책 (계획서 §6.6, §9.4):
+ * 정책 (계획서 §6.6, §9.4) — 보존 판정은 운영자 수정의 실제 기록 형식인 dot-path
+ * 항목(`"{컬럼}.{로케일}"`, HasUserOverrides updating 이벤트가 기록)을 기준으로 한다:
* - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가
- * - Case B: locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (사용자 수정 보존)
- * - Case C: locale 키 존재 + user_overrides 미등록 → 시드 값으로 덮어쓰기
- * - 비활성화/제거 시: user_overrides 등록 locale 은 JSON 에서 제거하지 않음
+ * - Case B: locale 키 존재 + user_overrides 에 `컬럼.로케일` 등록 → 건너뜀 (사용자 수정 보존)
+ * - Case C: locale 키 존재 + 해당 dot-path 미등록 → 시드 값으로 덮어쓰기
+ * (컬럼 전체 항목 `"subject"` 등은 시더 재실행 보호 선언이라 팩 병합을 막지 않는다)
+ * - 비활성화/제거 시: user_overrides 에 `컬럼.로케일` 등록된 locale 은 JSON 에서 제거하지 않음
*/
// audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님)
class SyncDatabaseTranslations
diff --git a/app/Models/IdentityMessageTemplate.php b/app/Models/IdentityMessageTemplate.php
index 4190b7fe..e9cd546a 100644
--- a/app/Models/IdentityMessageTemplate.php
+++ b/app/Models/IdentityMessageTemplate.php
@@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model
'is_active',
];
+ /**
+ * 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
+ * (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정)
+ *
+ * @var array
+ */
+ protected array $translatableTrackableFields = [
+ 'subject',
+ 'body',
+ ];
+
/**
* 활동 로그 추적 필드.
*
diff --git a/app/Models/NotificationTemplate.php b/app/Models/NotificationTemplate.php
index b15f53dd..72e8de99 100644
--- a/app/Models/NotificationTemplate.php
+++ b/app/Models/NotificationTemplate.php
@@ -27,6 +27,20 @@ class NotificationTemplate extends Model
'is_active',
];
+ /**
+ * 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
+ *
+ * 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을
+ * 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의
+ * 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정).
+ *
+ * @var array
+ */
+ protected array $translatableTrackableFields = [
+ 'subject',
+ 'body',
+ ];
+
/**
* 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제.
*/
@@ -110,13 +124,6 @@ class NotificationTemplate extends Model
return $this->belongsTo(User::class, 'updated_by');
}
- /**
- * 특정 채널의 템플릿 조회.
- *
- * @param Builder $query
- * @param string $channel
- * @return Builder
- */
/**
* 수신자 규칙이 설정되어 있는지 확인합니다.
*
@@ -127,6 +134,13 @@ class NotificationTemplate extends Model
return ! empty($this->recipients);
}
+ /**
+ * 특정 채널의 템플릿 조회.
+ *
+ * @param Builder $query
+ * @param string $channel
+ * @return Builder
+ */
public function scopeByChannel(Builder $query, string $channel): Builder
{
return $query->where('channel', $channel);
diff --git a/app/Repositories/LanguagePackTranslationRepository.php b/app/Repositories/LanguagePackTranslationRepository.php
index 6ba7b7ec..30f731ed 100644
--- a/app/Repositories/LanguagePackTranslationRepository.php
+++ b/app/Repositories/LanguagePackTranslationRepository.php
@@ -41,29 +41,37 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
*/
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
{
- $audit = [];
- $locale = $pack->locale;
+ // 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가
+ // 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이
+ // 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정).
+ app()->instance('user_overrides.seeding', true);
+ try {
+ $audit = [];
+ $locale = $pack->locale;
- if (! empty($seedBundle['permissions'])) {
- $this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
- }
- if (! empty($seedBundle['roles'])) {
- $this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
- }
- if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
- $this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
- }
- if (! empty($seedBundle['notifications'])) {
- $this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
- }
- if (! empty($seedBundle['identity_messages'])) {
- $this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
- }
- if (! empty($seedBundle['manifest'])) {
- $this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
- }
+ if (! empty($seedBundle['permissions'])) {
+ $this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
+ }
+ if (! empty($seedBundle['roles'])) {
+ $this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
+ }
+ if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
+ $this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
+ }
+ if (! empty($seedBundle['notifications'])) {
+ $this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
+ }
+ if (! empty($seedBundle['identity_messages'])) {
+ $this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
+ }
+ if (! empty($seedBundle['manifest'])) {
+ $this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
+ }
- return $audit;
+ return $audit;
+ } finally {
+ app()->forgetInstance('user_overrides.seeding');
+ }
}
/**
@@ -73,6 +81,23 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
* @return array> 감사 로그 항목
*/
public function stripLocaleFromPack(LanguagePack $pack): array
+ {
+ // applySeedFromPack 과 동일 — 제거도 시딩 컨텍스트 (user_overrides 오염 차단)
+ app()->instance('user_overrides.seeding', true);
+ try {
+ return $this->stripLocaleFromPackBody($pack);
+ } finally {
+ app()->forgetInstance('user_overrides.seeding');
+ }
+ }
+
+ /**
+ * stripLocaleFromPack 의 본문 (시딩 바인딩 하에서 실행).
+ *
+ * @param LanguagePack $pack 비활성화된 언어팩
+ * @return array> 감사 로그 항목
+ */
+ private function stripLocaleFromPackBody(LanguagePack $pack): array
{
$audit = [];
$locale = $pack->locale;
@@ -402,7 +427,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
}
$hasLocaleKey = array_key_exists($locale, $current);
- $isOverridden = isset($overrides[$column]);
+ $isOverridden = $this->isColumnLocaleOverridden($overrides, $column, $locale);
if ($hasLocaleKey && $isOverridden) {
$audit[] = [
@@ -413,6 +438,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
'locale' => $locale,
'reason' => 'user_overrides',
];
+
continue;
}
@@ -443,7 +469,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
continue;
}
- if (isset($overrides[$column])) {
+ if ($this->isColumnLocaleOverridden($overrides, $column, $locale)) {
$audit[] = [
'action' => 'preserved',
'table' => $row->getTable(),
@@ -452,6 +478,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
'locale' => $locale,
'reason' => 'user_overrides',
];
+
continue;
}
@@ -464,4 +491,27 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
$row->saveQuietly();
}
}
+
+ /**
+ * 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다.
+ *
+ * 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` —
+ * HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로
+ * 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤
+ * 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가
+ * 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정).
+ *
+ * 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은
+ * 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩
+ * 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음).
+ *
+ * @param array $overrides user_overrides 리스트
+ * @param string $column 다국어 JSON 컬럼명
+ * @param string $locale 대상 로케일
+ * @return bool 보존 대상 여부
+ */
+ protected function isColumnLocaleOverridden(array $overrides, string $column, string $locale): bool
+ {
+ return in_array($column.'.'.$locale, $overrides, true);
+ }
}
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/IdentityMessageTemplateService.php b/app/Services/IdentityMessageTemplateService.php
index ed882824..f690138d 100644
--- a/app/Services/IdentityMessageTemplateService.php
+++ b/app/Services/IdentityMessageTemplateService.php
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
+use App\Services\LanguagePack\LanguagePackSeedInjector;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
@@ -37,6 +38,7 @@ class IdentityMessageTemplateService
private readonly IdentityMessageTemplateRepositoryInterface $repository,
private readonly IdentityMessageDefinitionService $definitionService,
private readonly CacheInterface $cache,
+ private readonly LanguagePackSeedInjector $seedInjector,
) {}
/**
@@ -271,11 +273,19 @@ class IdentityMessageTemplateService
{
$coreDefinitions = $this->loadCoreMessageDefinitions();
- return HookManager::applyFilters(
+ $definitions = HookManager::applyFilters(
'core.identity.filter_default_message_definitions',
$coreDefinitions,
[]
);
+
+ // 활성 언어팩 seed 로케일 병합 — 시딩(seed.identity_messages.translations)과 같은 SSoT.
+ // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의
+ // ko/en 만으로 대체해 영구 소실시킨다 (notification 쪽 실사례의 동형 — #597 보완 실측).
+ // injectIdentityMessages 는 string 키('mail.purpose.signup' 등)로 seed 를 매칭하므로
+ // loadCoreMessageDefinitions 가 config 키를 보존해 넘긴다. 확장 리스너가 정수 키로
+ // append 한 항목은 seed 에 매칭되지 않아 그대로 통과한다.
+ return $this->seedInjector->injectIdentityMessages($definitions);
}
/**
@@ -292,13 +302,14 @@ class IdentityMessageTemplateService
$common = $this->commonVariables();
$result = [];
- foreach ($messages as $data) {
+ foreach ($messages as $key => $data) {
if (($data['variables'] ?? null) === '__common__') {
$data['variables'] = $common;
}
$data['extension_type'] = 'core';
$data['extension_identifier'] = 'core';
- $result[] = $data;
+ // config 키('mail.provider_default' 등)를 보존 — 언어팩 seed 매칭 키와 동일 (collectDefaultDefinitions 참조)
+ $result[$key] = $data;
}
return $result;
diff --git a/app/Services/LanguagePack/LanguagePackSeedInjector.php b/app/Services/LanguagePack/LanguagePackSeedInjector.php
index 48121990..84b85cf4 100644
--- a/app/Services/LanguagePack/LanguagePackSeedInjector.php
+++ b/app/Services/LanguagePack/LanguagePackSeedInjector.php
@@ -112,7 +112,7 @@ class LanguagePackSeedInjector
}
foreach ($definitions as $defIdx => $def) {
- $type = $def['type'] ?? null;
+ $type = $this->resolveDefinitionType($defIdx, $def);
if (! $type || ! isset($seed[$type])) {
continue;
}
@@ -166,7 +166,7 @@ class LanguagePackSeedInjector
continue;
}
foreach ($definitions as $defIdx => $def) {
- $type = $def['type'] ?? null;
+ $type = $this->resolveDefinitionType($defIdx, $def);
if (! $type || ! isset($seed[$type])) {
continue;
}
@@ -722,4 +722,30 @@ class LanguagePackSeedInjector
return $config;
}
+
+ /**
+ * 정의 배열 항목에서 알림 type 을 해석합니다.
+ *
+ * 코어 시더(`loadConfigSeed('core.notification_definitions', ...)`)는 config 원형
+ * (연관 배열 — 키가 곧 type, 항목에 type 필드 없음)을 그대로 필터에 넘기고,
+ * 확장 시더(ModuleManager/PluginManager)와 [기본값 복원] 경로는 각 항목에 type
+ * 필드를 포함한 리스트를 넘긴다. 양쪽 형태를 모두 수용한다 —
+ * 문자열 키가 있으면 그것이 type 이고, 아니면 항목의 type 필드를 본다.
+ * (형태 불일치 시 예외 없이 전 항목이 스킵되어 ja 가 조용히 주입되지 않는
+ * 회귀가 있었다 — 2026-08-24 #597 보완 실측. NotificationSeedInjectionTest 가 고정.)
+ *
+ * @param int|string $defIdx 정의 배열의 키
+ * @param mixed $def 정의 항목
+ * @return string|null 해석된 type (해석 불가 시 null)
+ */
+ private function resolveDefinitionType(int|string $defIdx, mixed $def): ?string
+ {
+ if (is_string($defIdx) && $defIdx !== '') {
+ return $defIdx;
+ }
+
+ $type = is_array($def) ? ($def['type'] ?? null) : null;
+
+ return is_string($type) && $type !== '' ? $type : null;
+ }
}
diff --git a/app/Services/LanguagePackService.php b/app/Services/LanguagePackService.php
index 36c613f4..c6484156 100644
--- a/app/Services/LanguagePackService.php
+++ b/app/Services/LanguagePackService.php
@@ -1814,10 +1814,21 @@ class LanguagePackService
}
}
- // 상태 복원 (DB 행이 finalizeInstall 에서 status 를 다시 쓰지 않은 경우만)
+ // 상태 복원 — 파일은 위에서 백업으로 원상 복원되므로 상태도 이전 상태로 되돌린다.
+ // Updating: 설치 트랜잭션 이전에 실패한 경우.
+ // Installed: 설치 트랜잭션이 installed 를 기록한 뒤(활성화 단계 이전/도중) 실패한 경우 —
+ // 여기서 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의
+ // 백엔드 번역이 오류·로그 없이 통째로 폴백된다 (#597 보완 실측에서 유사 상태 실측).
+ // 활성화가 성공적으로 끝난 뒤(active) 실패한 경우는 덮어쓰지 않는다.
try {
$current = $this->repository->findByIdentifier($identifier);
- if ($current && $current->status === LanguagePackStatus::Updating->value) {
+ $restorable = [
+ LanguagePackStatus::Updating->value,
+ LanguagePackStatus::Installed->value,
+ ];
+ if ($current
+ && $current->status !== $previousStatus
+ && in_array($current->status, $restorable, true)) {
$this->repository->update($current, ['status' => $previousStatus]);
}
} catch (Throwable $statusError) {
diff --git a/app/Services/NotificationTemplateService.php b/app/Services/NotificationTemplateService.php
index 9d652331..d4855cf2 100644
--- a/app/Services/NotificationTemplateService.php
+++ b/app/Services/NotificationTemplateService.php
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Models\NotificationTemplate;
+use App\Services\LanguagePack\LanguagePackSeedInjector;
use Illuminate\Database\Eloquent\Collection;
class NotificationTemplateService
@@ -33,6 +34,7 @@ class NotificationTemplateService
private readonly NotificationTemplateRepositoryInterface $templateRepository,
private readonly NotificationDefinitionRepositoryInterface $definitionRepository,
private readonly CacheInterface $cache,
+ private readonly LanguagePackSeedInjector $seedInjector,
) {}
/**
@@ -189,6 +191,11 @@ class NotificationTemplateService
['type' => $type, 'channel' => $channel]
);
+ // 활성 언어팩 seed 로케일 병합 — 시딩(seed.notifications.translations)과 같은 SSoT.
+ // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의
+ // ko/en 만으로 대체해 영구 소실시킨다 (2026-08-23 실사례 — #597 보완 실측).
+ $definitions = $this->mergeLanguagePackLocalesIntoDefaults($definitions);
+
foreach ($definitions as $def) {
if ($def['type'] === $type) {
foreach ($def['templates'] as $tpl) {
@@ -207,6 +214,36 @@ class NotificationTemplateService
return [];
}
+ /**
+ * 기본 정의 배열에 활성 언어팩 seed 로케일을 병합합니다.
+ *
+ * 코어 소유 정의는 활성 코어 팩(injectNotifications), 확장 소유 정의는
+ * 해당 확장의 활성 모듈/플러그인 팩(injectExtensionNotifications)이 공급한다.
+ * definition type 은 전역 고유(UNIQUE)이므로 확장별 호출이 다른 확장의
+ * 정의에 오병합될 여지는 없다.
+ *
+ * @param array> $definitions type 필드를 포함한 기본 정의 배열
+ * @return array> 활성 팩 로케일이 병합된 정의 배열
+ */
+ private function mergeLanguagePackLocalesIntoDefaults(array $definitions): array
+ {
+ $definitions = $this->seedInjector->injectNotifications($definitions);
+
+ $extensionIdentifiers = [];
+ foreach ($definitions as $def) {
+ $identifier = is_array($def) ? ($def['extension_identifier'] ?? null) : null;
+ if (is_string($identifier) && $identifier !== '' && $identifier !== 'core') {
+ $extensionIdentifiers[$identifier] = true;
+ }
+ }
+
+ foreach (array_keys($extensionIdentifiers) as $identifier) {
+ $definitions = $this->seedInjector->injectExtensionNotifications($definitions, $identifier);
+ }
+
+ return $definitions;
+ }
+
/**
* 미리보기용 렌더링.
*
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/database/seeders/IdentityMessageDefinitionSeeder.php b/database/seeders/IdentityMessageDefinitionSeeder.php
index 2468a621..9b1bf65b 100644
--- a/database/seeders/IdentityMessageDefinitionSeeder.php
+++ b/database/seeders/IdentityMessageDefinitionSeeder.php
@@ -72,20 +72,25 @@ class IdentityMessageDefinitionSeeder extends Seeder
* `extension_type='core'` / `extension_identifier='core'` 자동 주입.
* `variables: '__common__'` 마커는 commonVariables() 로 expand.
*
- * @return array>
+ * @return array> config 복합 키('mail.purpose.signup' 등) => 정의
*/
private function getDefaultDefinitions(): array
{
$messages = config('core.identity_messages', []);
$result = [];
- foreach ($messages as $data) {
+ foreach ($messages as $key => $data) {
if (($data['variables'] ?? null) === '__common__') {
$data['variables'] = $this->commonVariables();
}
$data['extension_type'] = 'core';
$data['extension_identifier'] = 'core';
- $result[] = $data;
+ // config 복합 키('mail.purpose.signup' 등)를 보존 — 언어팩 주입 필터
+ // (seed.identity_messages.translations)가 이 키로 seed 를 매칭한다.
+ // 리스트로 만들면 전 항목이 매칭 불가로 스킵되어 팩 로케일이 오류 없이
+ // 주입되지 않는다 (#597 에서 실측·교정 — IdentityMessageTemplateService
+ // ::loadCoreMessageDefinitions 의 동형 보존과 짝을 이룬다).
+ $result[$key] = $data;
}
return $result;
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/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/backend/notification-system.md b/docs/backend/notification-system.md
index 4c9b0b0f..523b7477 100644
--- a/docs/backend/notification-system.md
+++ b/docs/backend/notification-system.md
@@ -611,6 +611,49 @@ user_id 없는 비회원(주문자 이메일/이름만 보유)도 회원과 동
- 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다.
- `exclude_trigger_user` 는 회원(user_id) 기준이라 게스트 수신자를 깨뜨리지 않습니다.
+### 채널 메타의 화면 표시 키 (hidden_tab · tab_channels · tab_label_key · hidden_template_editor)
+
+확장이 `core.notification.filter_available_channels` 훅으로 등록하는 채널 메타는 임의 필드를 보존해 프론트(`availableChannels` 데이터소스)까지 그대로 도달한다. 관리자 알림 설정 화면(코어 `admin_settings` · 게시판 `admin_board_settings` · 이커머스 `admin_ecommerce_settings`)은 다음 범용 키를 해석한다. 특정 확장 이름을 알지 못하며, 키가 없으면 종전과 동일하게 동작한다.
+
+| 키 | 타입 | 효과 |
+| --- | --- | --- |
+| `hidden_tab` | bool | 채널 서브탭만 숨긴다. 채널 토글 카드·발송 축은 그대로 |
+| `tab_channels` | string[] | 이 채널의 탭이 대표하는 채널 id 목록. 목록 중 하나라도 활성 저장이면 탭을 노출한다(여러 채널을 하나의 탭으로 묶을 때) |
+| `tab_label_key` | string | 탭 라벨용 프론트 lang 키(`$t` 해석). 카드 라벨(`name_key`)과 분리 |
+| `hidden_template_editor` | bool | 알림 템플릿 [편집] 모달에서 코어의 언어탭·제목·본문·클릭 URL·변수 안내·[미리보기]·코어 [저장]을 숨긴다. 그 채널의 본문 규격이 코어 템플릿과 달라 확장이 편집기와 저장을 대신할 때 선언한다. 수신자 규칙·[취소]는 남는다 |
+
+`hidden_template_editor` 를 선언한 확장은 편집 모달의 확장 지점 두 곳에 자기 UI 를 주입한다(3면 공통 이름 — extension_point 파일 1본으로 세 화면에 주입된다).
+
+| extension_point | 위치 | props |
+| --- | --- | --- |
+| `notification_template_form_sections` | 수신자 규칙 다음, 코어 언어탭 앞 | `definition` · `template` · `channel` · `modalId` · `stateKey`(면별 코어 모달 상태 키) · `saveEndpoint`(코어 채널 템플릿 PUT) · `refetchDataSourceId`(면별 알림 정의 목록) |
+| `notification_template_form_footer_actions` | [취소] 와 코어 [저장] 사이 | 위와 동일 |
+
+확장은 이 props 만으로 코어 저장 계약을 이행한다 — 코어 엔드포인트·상태 키를 리터럴로 적지 않는다. 코어 채널 템플릿의 수신자 규칙은 `_global?.[extensionPointProps.stateKey]?.recipients` 로 읽어 바뀐 경우에만 `saveEndpoint` 로 PUT 한다(매번 PUT 하면 코어가 행을 "사용자 수정" 으로 표시한다). 선례: `plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_*.json`. 행 하단 확장 지점 `notification_definition_row_footer`(props `definition` · `activeChannel`)는 상태 요약 같은 읽기 전용 UI 용이다.
+
+### 정의 타입 라벨의 공급처
+
+관리자 알림 설정 화면의 정의 목록 제목은 DB `name` 컬럼이 아니라 프론트 다국어 키
+`admin.settings.notification_definitions.types.{type}` 로 렌더된다. 이 키의 공급처는 정의 소유자를 따른다.
+
+| 정의 소유 | 라벨 위치 |
+| --- | --- |
+| 코어 (`config/core.php` `notification_definitions`) | admin 템플릿 `lang/partial/{ko,en}/admin.json` 의 `settings.notification_definitions.types` (ja 는 템플릿 언어팩) |
+| 모듈 | 각 모듈의 알림 설정 화면과 lang 이 담당 |
+
+키가 없으면 예외도 경고도 없이 원시 키 문자열이 화면 제목으로 그대로 노출된다. 코어 정의를
+추가하는 변경은 반드시 admin 템플릿 라벨(ko·en)과 템플릿 언어팩(ja)을 함께 추가해야 하며,
+코어 정의 전수 ↔ 템플릿 라벨 패리티는 정적 검사(테스트)가 자동 대조한다.
+
+## 채널 템플릿을 시드하는 확장의 [기본값 복원] 의무
+
+확장이 시딩 필터(`seed.notifications.translations` 등)로 자기 채널의 템플릿 행을 알림 정의에 끼워 넣으면, 같은 증강을
+`core.notification.filter_default_definitions` 에도 걸어야 한다. 관리자 화면의 [기본값 복원]은
+`NotificationTemplateService::getDefaultTemplateData()` 가 이 필터를 통과시킨 정의 배열에서 해당 채널의 template 을 찾아
+복원하므로, 시드 출처와 복원 출처가 같은 함수가 아니면 그 채널 행의 복원은 "기본 템플릿 데이터를 찾을 수 없습니다" 로 끝난다
+(시드는 되는데 복원만 안 되는 상태라 오류 로그도 남지 않는다). 모듈이 자기 정의를 이 필터에 보태는 priority(20) 뒤에서 돌아야
+모듈 정의도 증강된다. 선례: `plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php`.
+
### 채널별 비회원 발송 허용 (allow_guest)
채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다.
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/docs/extension/language-packs.md b/docs/extension/language-packs.md
index 49529cce..0fa7678c 100644
--- a/docs/extension/language-packs.md
+++ b/docs/extension/language-packs.md
@@ -287,6 +287,48 @@ foreach ($config['categories'] as $cat) { ... }
- `permissions.json` → `{scope}.{target}.permissions.translations` 필터 (3-레벨 트리: module/categories/permissions)
- 기타 → `seed.{target}.{entity}.translations` 필터 (단순 entity 시드)
+### 복원 경로의 언어팩 병합
+
+[기본값 복원](알림 템플릿·본인인증 메시지)은 위 시딩 필터가 아니라 자체 기본값 필터
+(`core.notification.filter_default_definitions` / `core.identity.filter_default_message_definitions`)를
+탄다. 복원 기본값에는 **활성 언어팩 seed 로케일이 반드시 병합**되어야 한다 — 병합하지 않으면
+복원이 팩이 주입해 둔 로케일(ja 등)을 config 의 ko/en 만으로 대체해 영구 소실시키며,
+오류도 로그도 남지 않는다. 코어 서비스(NotificationTemplateService·IdentityMessageTemplateService)가
+필터 적용 후 시딩과 같은 주입기(SSoT)로 병합하며, 정적 검사(테스트)가 이 계약을 고정한다.
+
+시딩 필터의 페이로드는 **발화 주체의 원형 키를 보존**해야 한다 — 코어 알림 시더는 config
+원형(연관 배열, 키가 곧 type), 본인인증은 복합 키(`mail.purpose.signup` 등)가 매칭 키다.
+키를 버리고 리스트로 만들면 주입기가 전 항목을 스킵해 팩 로케일이 오류 없이 주입되지 않는다.
+알림 주입기는 두 형태(문자열 키 우선, 없으면 type 필드)를 수용한다.
+
+### 사용자 수정 보존 판정과 시딩 컨텍스트
+
+언어팩 병합·제거의 사용자 수정 보존 판정은 운영자 수정의 실제 기록 형식인 **dot-path 항목**
+(`user_overrides` 의 `"{컬럼}.{로케일}"`)을 기준으로 한다. 컬럼 전체 항목(`"subject"`)은 시더
+재실행의 컬럼 보호 선언이며 팩 병합을 막지 않는다 — 막으면 팩 업데이트가 자기 로케일 번역을
+갱신하지 못한다.
+
+이를 위해 두 가지가 함께 지켜져야 한다:
+
+- 다국어 JSON 컬럼을 추적하는 모델은 `translatableTrackableFields` 를 선언한다 — 미선언 시
+ 운영자 수정이 컬럼 전체 항목으로 기록되어 로케일 단위 보존이 불가능해진다.
+- 언어팩 주입·제거처럼 시스템이 수행하는 저장은 시딩 컨텍스트(`user_overrides.seeding` 바인딩)
+ 안에서 실행한다 — 바인딩 없이 저장하면 주입 자체가 "사용자 수정"으로 오인 기록되어 이후
+ 시더 재실행의 컬럼 갱신이 영구히 얼어붙는다.
+
+### 실패한 업데이트의 상태 복원
+
+`language-pack:update` 가 설치 트랜잭션 이후(활성화 단계)에 실패하면 팩 상태를 이전 상태로
+복원한다. 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의 백엔드 번역이
+안내 없이 기본 로케일로 폴백된다.
+
+### 같은 프로세스 활성화와 필터 인스턴스
+
+시더 번역 필터 클로저는 boot 시점의 injector→registry **인스턴스**를 캡처한다. 활성 팩 목록
+캐시를 갱신할 때는 싱글톤을 유지한 채 `LanguagePackRegistry::invalidate()` 로 내부 캐시만
+비운다 — `forgetInstance` 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시가 남아, 같은
+프로세스에서 활성화된 신규 팩이 시더 필터에 보이지 않는다(오류 없이 해당 로케일만 미주입).
+
### IDV 도메인 lang pack 커버리지
`config/core.php` 의 본인인증 SSoT 4개 블록 중 lang pack seed 대상은 1개:
@@ -450,7 +492,20 @@ G7 는 일본어 번들 언어팩 12종을 공식 제공합니다. ko 원본을
- 통화/숫자 단위 변환 금지 (런타임 처리)
- 공식체(です·ます) 강제, 친근체 금지
- 한글 미포함 value(영문/숫자/기호만) 는 번역 스킵 → 원문 유지
-- 고정 용어집 50항목으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
+- 고정 용어집으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
+
+### 용어집 등록 의무
+
+번역 결과의 표기는 고정 용어집이 정한다. 용어집에 없는 도메인 용어는 어간만 번역되어
+**원본 로케일 문자가 값에 그대로 남는다** — 예외도 오류도 나지 않고, 키 대칭 검사도 값은 보지 않으므로
+그 로케일로 화면을 열기 전까지 드러나지 않는다.
+
+- 새 도메인 용어를 도입하면 번역 빌드 **전에** 용어집에 등록한다
+- 이미 생성된 값을 손으로 고쳤다면 용어집도 함께 고친다 — 용어집을 두면 다음 키에서 같은 오역이 되풀이된다
+- 같은 용어는 팩 전체에서 한 표기로 통일한다 (용어집이 단일 출처)
+
+정적 검사가 번역 값의 원본 로케일 문자 잔존을 검출한다. 매니페스트와 CHANGELOG,
+그리고 한국어 주석은 대상이 아니다.
### 설치 및 활성화
diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md
index ed9e34e5..7109d3b3 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-24
+
+### 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-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
index 1e790bd5..5e0cb145 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/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.3] - 2026-08-24
+
+### Fixed
+
+- 배송업체 이름(한진·롯데·로젠)이 일본어 화면에서 "택배" 부분만 한국어로 표시되던 문제를 수정했습니다. 관리자 배송정책 화면과 초기 배송업체 데이터 모두 일본어 표기로 통일했습니다.
+- 모듈 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
+
## [1.1.2] - 2026-08-22
### Added
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json
index 74d49a26..c741e2fd 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json
@@ -16,9 +16,9 @@
},
"carrier": {
"cj": "CJ大韓通運",
- "hanjin": "韓進택배",
- "lotte": "ロッテ택배",
- "logen": "ロジェン택배",
+ "hanjin": "ハンジン宅配",
+ "lotte": "ロッテ宅配",
+ "logen": "ロジェン宅配",
"ups": "UPS",
"fedex": "FedEx",
"dhl": "DHL",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
index 297a61d3..44b5c565 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
- "version": "1.1.2",
+ "version": "1.1.3",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json
index cfc77ecc..9c62bbf9 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json
@@ -1,4 +1,4 @@
{
"name": "eコマース",
- "description": "그누보드7 eコマース モジュール - 商品、注文、決済管理"
+ "description": "グヌボード7 eコマース モジュール - 商品、注文、決済管理"
}
diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json
index d286b74c..e4b536e8 100644
--- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json
+++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json
@@ -3,13 +3,13 @@
"name": "CJ大韓通運"
},
"hanjin": {
- "name": "韓進택배"
+ "name": "ハンジン宅配"
},
"lotte": {
- "name": "ロッテ택배"
+ "name": "ロッテ宅配"
},
"logen": {
- "name": "ロジェン택배"
+ "name": "ロジェン宅配"
},
"ups": {
"name": "UPS"
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..001e936e
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/CHANGELOG.md
@@ -0,0 +1,18 @@
+# Changelog
+
+이 언어팩의 모든 주요 변경사항을 기록합니다.
+형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
+[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+
+## [1.0.0] - 2026-08-24
+
+### Added
+
+- 비즈뿌리오 메시지 발송 플러그인(sirsoft-message_bizppurio)의 일본어 번들 언어팩 초기 제공
+- 관리자 환경설정 문구(비즈뿌리오 아이디·비밀번호·발신번호·알림톡 발신프로필키·결과 수신 주소, 검수 모드와 운영 전환 경고)의 일본어 번역
+- 알림 편집 창에 통합된 알림톡 템플릿·문자(SMS) 섹션의 제목·안내 문구와 승인 여부 배지(승인됨 / 미승인)의 일본어 번역
+- 검수자 전달 의견 입력란의 라벨·안내 문구의 일본어 번역
+- 알림톡 템플릿 작성·검수 신청·승인 관리 화면 문구의 일본어 번역
+- 템플릿 통합 관리 화면과 발송 이력 화면 문구의 일본어 번역
+- 발송 결과 안내 문구의 일본어 번역
+- 템플릿 작성 시 입력값 오류 안내의 일본어 번역 — 항목 이름까지 일본어로 표시됩니다.
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..432bcd5f
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/backend/ja/messages.php
@@ -0,0 +1,160 @@
+ [
+ 'sms' => 'SMS',
+ 'lms' => 'LMS',
+ 'alimtalk' => 'アラートトーク',
+ ],
+ 'channel_group' => [
+ 'text' => '文字',
+ 'alimtalk' => '通知トーク',
+ ],
+ 'status' => [
+ 'pending' => '待機中',
+ 'sent' => '送信中',
+ 'success' => '成功',
+ 'failed' => '失敗',
+ ],
+ 'source' => [
+ 'auto' => '自動',
+ 'manual' => '手動',
+ 'bulk' => '一括',
+ ],
+ 'result_category' => [
+ 'success' => '成功',
+ 'retry' => '再試行',
+ 'permanent_failure' => '永久失敗',
+ 'balance_low' => '残高不足',
+ ],
+ 'channels' => [
+ 'source_label' => 'ビズプリオ',
+ 'sms' => [
+ 'name' => 'ビズプリオ 文字',
+ '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' => 'ビズプリオ認証トークン発行に失敗しました。',
+ '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' => 'アラートトーク発信プロフィールキーを先に設定してください。',
+ 'image_unreadable' => 'アップロードした画像ファイルを読み込めません。',
+ ],
+ 'send_skipped' => [
+ 'alimtalk_template_not_approved' => '承認されたアラートトークテンプレートがないか、アラートトーク送信がオフになっているため送信をスキップしました。(通知タイプ: :type)',
+ 'sms_template_missing' => 'SMS本文が設定されていないため送信をスキップしました。(通知タイプ: :type)',
+ 'recipient_phone_missing' => '受信者の電話番号がないため送信をスキップしました。(通知タイプ: :type)',
+ 'message_body_empty' => '送信本文が空いているため送信をスキップしました。(通知タイプ: :type)',
+ ],
+ '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' => 'テンプレートコードを採番できませんでした。しばらくしてからもう一度お試しください。',
+ ],
+ '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' => '本文',
+ 'comment' => '審査担当者への伝達事項',
+ '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とパスワードが正しいです。',
+ '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..d7360f5e
--- /dev/null
+++ b/lang-packs/_bundled/g7-plugin-sirsoft-message_bizppurio-ja/frontend/ja.json
@@ -0,0 +1,304 @@
+{
+ "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": "ビズプリオモジュールパスワードを入力してください。(ビズプリオコンソール > モジュール連携環境設定 > モジュールパスワード変更)"
+ },
+ "connection_check": {
+ "label": "接続確認",
+ "hint": "保存されたID·パスワードが有効かどうか、クリックしてすぐに確認してください。(保存後に反映されます)",
+ "button": "接続確認",
+ "checking": "確認中...",
+ "success": "認証が正常に確認されました。IDとパスワードが正しいです。",
+ "failed": "認証確認に失敗しました。",
+ "unsaved_changes": "変更内容を先に保存してください。"
+ },
+ "api_key": {
+ "label": "API キー",
+ "hint": "API キーは ビズプリオ カスタマーセンターに ID とともに申し込むと確認後、発行されます。"
+ },
+ "sender_number": {
+ "label": "発信番号",
+ "hint": "文字・アラートトーク発送に使用する発信電話番号です。"
+ },
+ "sender_key": {
+ "label": "アラートトーク 発信プロフィール キー",
+ "hint": "アラートトーク 発送・テンプレート確認に使用する発信プロフィール キー(40文字)です。"
+ }
+ },
+ "report": {
+ "section_title": "レポート受信 設定",
+ "hint": "ビズプリオ は文字・アラートトーク発送後、成功/失敗の結果をこのアドレスに転送(URL PUSH)するレポート受信方式を提供します。以下のアドレスを ビズプリオ ビジネスチーム(または管理コンソールのレポート受信 設定)に登録してください。",
+ "note": "登録すると発送結果が履歴に自動記録され、実際に到達したかどうかを確認できます。登録前は発送リクエストまで確認され、最終結果(成功/失敗)は更新されません。",
+ "copy": "コピー",
+ "copied": "レポート受信アドレスがクリップボードにコピーされました。"
+ },
+ "tabs": {
+ "connection": "環境設定",
+ "templates": "通知テンプレート管理"
+ },
+ "preparation": {
+ "intro": "文字・カカオ アラートトークを発送するには、まず ビズプリオ コンソールで以下の事前準備を完了する必要があります。",
+ "sms_label": "文字(SMS/LMS)",
+ "sms_sender": "発信番号を登録してください。",
+ "kakao_label": "カカオ アラートトーク",
+ "kakao_channel": "カカオトーク ビジネスチャネルを作成し、発信プロフィールを登録してください。",
+ "kakao_template": "アラートトークテンプレートは管理者の通知設定(ビズプリオタブ)で作成して審査を申請します。",
+ "kakao_apikey": "カスタマーセンターに API キーをリクエストしてください。",
+ "console_link": "ビズプリオ コンソールを開く"
+ }
+ },
+ "channels": {
+ "bizppurio_tab": "ビズプリオ"
+ },
+ "binding": {
+ "variables_hint": "提供変数(クリックすると本文のカーソル位置に挿入されます)"
+ },
+ "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": "ビズプリオ送信結果"
+ }
+ },
+ "template": {
+ "tab_guide": "各通知の[編集]でアラートトークのテンプレートとSMS本文をまとめて作成し、審査を申請してください。カカオの承認後にアラートトークが自動送信され、代替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本文を編集",
+ "sms_label": "SMS",
+ "on": "使用",
+ "off": "未使用",
+ "alimtalk_disabled": "送信停止中"
+ },
+ "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": "保存して審査申請",
+ "request_comment": "審査担当者への伝達事項",
+ "request_comment_placeholder": "例: #{name}=山田太郎, #{order_number}=20260823-0001",
+ "request_comment_hint": "審査申請時にカカオの審査担当者へ一緒に送られます(任意・500文字以内)。カカオの審査ガイドは本文の変数ごとに例示値を求めるため、変数ごとの例を書いておくと差し戻しを減らせます。保存はされず、申請時にのみ送信されます。",
+ "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": "閉じる"
+ },
+ "approval": {
+ "approved": "承認済み",
+ "not_approved": "未承認"
+ },
+ "section": {
+ "alimtalk_title": "アラートトークテンプレート",
+ "sms_title": "SMS",
+ "alimtalk_enabled": "アラートトーク送信を使用",
+ "locked_hint": "審査中または承認済みのテンプレートは内容を変更できません。変更するには先に[申請取消]または[編集(承認取消)]を押してください。",
+ "saved_toast": "通知テンプレートを保存しました。",
+ "loading": "テンプレートを読み込み中…"
+ }
+ },
+ "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": {
+ "readiness": {
+ "title": "アラートトークテンプレートを使用するには、以下の設定が必要です",
+ "go_settings": "環境設定に移動",
+ "missing_label": "未設定項目",
+ "api_key_missing": "カカオ管理API キー",
+ "sender_key_missing": "アラートトーク送信プロフィールキー",
+ "note": "上記項目を環境設定タブで入力すると、テンプレートの作成・審査申請が可能になります。実際の送信は検査モードをオフにして運用に切り替えた後、承認されたテンプレートのみ使用できます。"
+ },
+ "list": {
+ "refresh": "更新"
+ },
+ "link_type": {
+ "WL": "ウェブリンク",
+ "AL": "アプリリンク",
+ "DS": "配送追跡",
+ "BK": "ボットキーワード",
+ "MD": "メッセージ転送",
+ "AC": "チャネル追加",
+ "BC": "相談トーク転換",
+ "BT": "ボット転換",
+ "TN": "電話をかける",
+ "MP": "地図表示",
+ "P1": "画像セキュア送信",
+ "P2": "個人情報利用",
+ "P3": "ワンクリック決済"
+ }
+ }
+}
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/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..5209359c 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,13 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.0.7] - 2026-08-24
+
+### 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..83ae197c 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 サイト ハンドル",
@@ -1860,7 +1862,9 @@
"types": {
"welcome": "会員登録ウェルカム",
"reset_password": "パスワード再設定",
- "password_changed": "パスワード変更通知"
+ "password_changed": "パスワード変更通知",
+ "sitemap_regenerated": "サイトマップ再生成完了",
+ "sitemap_regenerate_failed": "サイトマップ再生成失敗"
},
"channels_label": "チャネル:",
"edit_modal": {
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-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
index 4dadeb0c..b8efdd12 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
+++ b/lang-packs/_bundled/g7-template-sirsoft-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.1.2] - 2026-08-24
+
+### Fixed
+
+- 첫 화면 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
+
## [1.1.1] - 2026-08-19
### Added
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json
index b3117745..f4647833 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json
@@ -1,7 +1,7 @@
{
"welcome": {
"title": "いらっしゃいませ!",
- "description": "그누보드7 コミュニティへようこそ。",
+ "description": "グヌボード7 コミュニティへようこそ。",
"login_prompt": "ログインしてコミュニティに参加してください。",
"greeting": "こんにちは、{{name}}さん!"
},
diff --git a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
index 70dc6acc..40396a18 100644
--- a/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
+++ b/lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
@@ -12,7 +12,7 @@
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
},
- "version": "1.1.1",
+ "version": "1.1.2",
"license": "MIT",
"scope": "template",
"target_identifier": "sirsoft-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..06a07438 100644
--- a/modules/_bundled/sirsoft-board/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-board/CHANGELOG.md
@@ -4,6 +4,18 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.1.0] - 2026-08-24
+
+### Added
+
+- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
+- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
+
+### Changed
+
+- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
+- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
+
## [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/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/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts
new file mode 100644
index 00000000..6f3604c5
--- /dev/null
+++ b/modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-notification-template-modal-extension.test.ts
@@ -0,0 +1,138 @@
+// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio)
+// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다.
+/**
+ * 게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름)
+ *
+ * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록:
+ * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞)
+ * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이)
+ * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다
+ * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일.
+ *
+ * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은
+ * 기대값을 함께 고치면 통과한다.
+ *
+ * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections
+ */
+
+import { describe, it, expect } from 'vitest';
+import modal from '../../../layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json';
+
+type Node = Record;
+const PREFIX = 'board_';
+const STATE_KEY = 'board_notification_template_form_modal';
+const MODAL_ID = 'modal_board_notification_template_edit';
+const REFETCH_ID = 'boardNotificationDefinitions';
+
+const findById = (node: unknown, id: string): Node | null => {
+ if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; }
+ if (node && typeof node === 'object') {
+ if ((node as Node).id === id) return node as Node;
+ for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; }
+ }
+ return null;
+};
+const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => {
+ if (Array.isArray(node)) {
+ const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id);
+ if (idx >= 0) return { arr: node, idx };
+ for (const n of node) { const r = findParentArray(n, id); if (r) return r; }
+ return null;
+ }
+ if (node && typeof node === 'object') {
+ for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; }
+ }
+ return null;
+};
+const evalIf = (expr: string, scope: Record): boolean => {
+ const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
+ const names = Object.keys(scope);
+ // eslint-disable-next-line no-new-func
+ return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n])));
+};
+const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({
+ availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } },
+ _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } },
+});
+
+const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId'];
+
+describe('게시판 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => {
+ it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => {
+ const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`);
+ expect(loc).toBeTruthy();
+ const { arr, idx } = loc!;
+ expect(arr[idx].type).toBe('extension_point');
+ expect(arr[idx].name).toBe('notification_template_form_sections');
+ expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`);
+ expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`);
+ });
+
+ it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => {
+ const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
+ const right = footer.children[1].children as Node[];
+ expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']);
+ expect(right[1].name).toBe('notification_template_form_footer_actions');
+ expect(JSON.stringify(right[0])).toContain('$t:common.cancel');
+ expect(JSON.stringify(right[2])).toContain('$t:common.save');
+ });
+
+ it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => {
+ const ep = findById(modal, id)!;
+ expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort());
+ expect(ep.props.modalId).toBe(MODAL_ID);
+ expect(ep.props.stateKey).toBe(STATE_KEY);
+ expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID);
+ expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`);
+ expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`);
+ expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`);
+ expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`);
+ });
+});
+
+describe('게시판 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => {
+ const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info'];
+
+ it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => {
+ const node = findById(modal, `${PREFIX}${id}`)!;
+ expect(node.if, `${id} if`).toBeTruthy();
+ expect(evalIf(node.if, scope('alimtalk', true))).toBe(false);
+ expect(evalIf(node.if, scope('mail', undefined))).toBe(true);
+ expect(evalIf(node.if, scope('sms', false))).toBe(true);
+ });
+
+ it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => {
+ const node = findById(modal, `${PREFIX}template_click_url_input`)!;
+ expect(evalIf(node.if, scope('database', undefined))).toBe(true);
+ expect(evalIf(node.if, scope('mail', undefined))).toBe(false);
+ expect(evalIf(node.if, scope('database', true))).toBe(false);
+ });
+
+ it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => {
+ const node = findById(modal, `${PREFIX}template_variables_info`)!;
+ const noVars = { ...scope('mail', undefined) } as Node;
+ noVars._global[STATE_KEY].definition = { variables: [] };
+ expect(evalIf(node.if, noVars)).toBe(false);
+ });
+
+ it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => {
+ const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
+ const preview = footer.children[0].children[0] as Node;
+ const right = footer.children[1].children as Node[];
+ const cancel = right[0];
+ const save = right[2];
+ for (const b of [preview, save]) {
+ expect(evalIf(b.if, scope('alimtalk', true))).toBe(false);
+ expect(evalIf(b.if, scope('mail', undefined))).toBe(true);
+ }
+ expect(cancel.if).toBeUndefined();
+ });
+
+ it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => {
+ for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) {
+ const node = findById(modal, `${PREFIX}${id}`)!;
+ expect(node, id).toBeTruthy();
+ expect(String(node.if ?? '')).not.toContain('hidden_template_editor');
+ }
+ });
+});
diff --git a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json
index 830721d2..2cd46c2c 100644
--- a/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json
+++ b/modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_notification_template_edit.json
@@ -500,8 +500,7 @@
]
}
],
- "props": {
- }
+ "props": {}
}
]
}
@@ -528,6 +527,21 @@
}
]
},
+ {
+ "id": "board_template_form_sections_ext",
+ "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.",
+ "type": "extension_point",
+ "name": "notification_template_form_sections",
+ "props": {
+ "definition": "{{_global.board_notification_template_form_modal?.definition}}",
+ "template": "{{_global.board_notification_template_form_modal?.template}}",
+ "channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}",
+ "modalId": "modal_board_notification_template_edit",
+ "stateKey": "board_notification_template_form_modal",
+ "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}",
+ "refetchDataSourceId": "boardNotificationDefinitions"
+ }
+ },
{
"id": "board_template_lang_tabs",
"type": "basic",
@@ -559,7 +573,8 @@
}
]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "board_template_subject_input",
@@ -613,7 +628,8 @@
}
]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "board_template_body_input",
@@ -668,13 +684,14 @@
}
]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "board_template_click_url_input",
"type": "basic",
"name": "Div",
- "if": "{{_global.board_notification_template_form_modal?.template?.channel === 'database'}}",
+ "if": "{{(_global.board_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
"children": [
{
"type": "basic",
@@ -724,7 +741,7 @@
"id": "board_template_variables_info",
"type": "basic",
"name": "Div",
- "if": "{{(_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0}}",
+ "if": "{{((_global.board_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
"props": {
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
},
@@ -841,17 +858,18 @@
"target": "modal_board_notification_template_preview"
}
],
- "onError": [
- {
- "handler": "toast",
- "params": {
- "type": "error",
- "message": "{{error.message}}"
- }
- }
- ]
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "type": "error",
+ "message": "{{error.message}}"
+ }
+ }
+ ]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
}
]
},
@@ -879,6 +897,21 @@
}
]
},
+ {
+ "id": "board_template_form_footer_ext",
+ "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.",
+ "type": "extension_point",
+ "name": "notification_template_form_footer_actions",
+ "props": {
+ "definition": "{{_global.board_notification_template_form_modal?.definition}}",
+ "template": "{{_global.board_notification_template_form_modal?.template}}",
+ "channel": "{{_global.board_notification_template_form_modal?.template?.channel ?? ''}}",
+ "modalId": "modal_board_notification_template_edit",
+ "stateKey": "board_notification_template_form_modal",
+ "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.board_notification_template_form_modal?.template?.id}}",
+ "refetchDataSourceId": "boardNotificationDefinitions"
+ }
+ },
{
"type": "basic",
"name": "Button",
@@ -978,7 +1011,8 @@
]
}
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.board_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
}
]
}
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-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..68641e77 100644
--- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
+++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md
@@ -4,6 +4,21 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [1.2.0] - 2026-08-24
+
+### Added
+
+- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
+
+### Changed
+
+- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
+- 이커머스 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
+
+### 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/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 | 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-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts
new file mode 100644
index 00000000..5bde5a12
--- /dev/null
+++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-notification-template-modal-extension.test.ts
@@ -0,0 +1,138 @@
+// e2e:allow 구조 단언 + 조건식 실평가 Vitest — 브라우저 흐름은 플러그인(sirsoft-message_bizppurio)
+// tests/Playwright/specs/admin/template-lifecycle-matrix.spec.ts 가 코어 면 대표로 담당한다.
+/**
+ * 이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종 + hidden_template_editor 게이트 (코어 admin_basic 모달과 동형, 면 식별자만 다름)
+ *
+ * 채널을 소유한 확장이 그 채널 전용 편집 UI 를 같은 모달에 녹일 수 있도록:
+ * - 본문: `notification_template_form_sections` (수신자 규칙 다음, 코어 제목/본문 앞)
+ * - 푸터: `notification_template_form_footer_actions` ([취소] 와 코어 [저장] 사이)
+ * - 채널 메타 `hidden_template_editor: true` 이면 코어 언어탭/제목/본문/클릭 URL/변수 안내/미리보기/[저장]을 숨긴다
+ * (확장이 본문 편집과 저장을 대신한다). 플래그가 없는 채널(메일·사이트내 알림)은 종전과 동일.
+ *
+ * 조건은 문자열 동일성이 아니라 실평가로 판정한다 — 게이트 식을 잘못 고쳐도 리터럴 단언은
+ * 기대값을 함께 고치면 통과한다.
+ *
+ * @effects core_modal_hides_editor_for_hidden_template_editor_channel, edit_modal_hosts_alimtalk_and_sms_sections
+ */
+
+import { describe, it, expect } from 'vitest';
+import modal from '../../../layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json';
+
+type Node = Record;
+const PREFIX = 'ecommerce_';
+const STATE_KEY = 'ecommerce_notification_template_form_modal';
+const MODAL_ID = 'modal_ecommerce_notification_template_edit';
+const REFETCH_ID = 'ecommerceNotificationDefinitions';
+
+const findById = (node: unknown, id: string): Node | null => {
+ if (Array.isArray(node)) { for (const n of node) { const r = findById(n, id); if (r) return r; } return null; }
+ if (node && typeof node === 'object') {
+ if ((node as Node).id === id) return node as Node;
+ for (const v of Object.values(node as Node)) { const r = findById(v, id); if (r) return r; }
+ }
+ return null;
+};
+const findParentArray = (node: unknown, id: string): { arr: Node[]; idx: number } | null => {
+ if (Array.isArray(node)) {
+ const idx = node.findIndex((n) => n && typeof n === 'object' && n.id === id);
+ if (idx >= 0) return { arr: node, idx };
+ for (const n of node) { const r = findParentArray(n, id); if (r) return r; }
+ return null;
+ }
+ if (node && typeof node === 'object') {
+ for (const v of Object.values(node as Node)) { const r = findParentArray(v, id); if (r) return r; }
+ }
+ return null;
+};
+const evalIf = (expr: string, scope: Record): boolean => {
+ const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
+ const names = Object.keys(scope);
+ // eslint-disable-next-line no-new-func
+ return Boolean(new Function(...names, `return (${body});`)(...names.map((n) => scope[n])));
+};
+const scope = (channel: string, hidden: boolean | undefined, extra: Record = {}) => ({
+ availableChannels: { data: { channels: [{ id: channel, ...(hidden === undefined ? {} : { hidden_template_editor: hidden }) }] } },
+ _global: { [STATE_KEY]: { template: { channel }, definition: { variables: [{ key: 'name' }] }, ...extra } },
+});
+
+const EP_PROPS = ['definition', 'template', 'channel', 'modalId', 'stateKey', 'saveEndpoint', 'refetchDataSourceId'];
+
+describe('이커머스 알림 템플릿 편집 모달 — 확장 슬롯 2종', () => {
+ it('본문 슬롯은 수신자 규칙 바로 다음, 언어 탭 앞에 놓인다', () => {
+ const loc = findParentArray(modal, `${PREFIX}template_form_sections_ext`);
+ expect(loc).toBeTruthy();
+ const { arr, idx } = loc!;
+ expect(arr[idx].type).toBe('extension_point');
+ expect(arr[idx].name).toBe('notification_template_form_sections');
+ expect(arr[idx - 1].id).toBe(`${PREFIX}template_recipients_section`);
+ expect(arr[idx + 1].id).toBe(`${PREFIX}template_lang_tabs`);
+ });
+
+ it('푸터 슬롯은 [취소] 와 코어 [저장] 사이에 놓인다', () => {
+ const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
+ const right = footer.children[1].children as Node[];
+ expect(right.map((n: Node) => n.type ?? n.name)).toEqual(['basic', 'extension_point', 'basic']);
+ expect(right[1].name).toBe('notification_template_form_footer_actions');
+ expect(JSON.stringify(right[0])).toContain('$t:common.cancel');
+ expect(JSON.stringify(right[2])).toContain('$t:common.save');
+ });
+
+ it.each([`${PREFIX}template_form_sections_ext`, `${PREFIX}template_form_footer_ext`])('%s 은 확장이 필요한 props 7종을 면 전용 값으로 전달한다', (id) => {
+ const ep = findById(modal, id)!;
+ expect(Object.keys(ep.props).sort()).toEqual([...EP_PROPS].sort());
+ expect(ep.props.modalId).toBe(MODAL_ID);
+ expect(ep.props.stateKey).toBe(STATE_KEY);
+ expect(ep.props.refetchDataSourceId).toBe(REFETCH_ID);
+ expect(ep.props.channel).toBe(`{{_global.${STATE_KEY}?.template?.channel ?? ''}}`);
+ expect(ep.props.saveEndpoint).toBe(`{{'/api/admin/notification-templates/' + _global.${STATE_KEY}?.template?.id}}`);
+ expect(ep.props.definition).toBe(`{{_global.${STATE_KEY}?.definition}}`);
+ expect(ep.props.template).toBe(`{{_global.${STATE_KEY}?.template}}`);
+ });
+});
+
+describe('이커머스 알림 템플릿 편집 모달 — hidden_template_editor 게이트 실평가', () => {
+ const GATED = ['template_lang_tabs', 'template_subject_input', 'template_body_input', 'template_variables_info'];
+
+ it.each(GATED)('%s: 플래그 채널이면 숨김, 플래그 없음/false 채널이면 노출', (id) => {
+ const node = findById(modal, `${PREFIX}${id}`)!;
+ expect(node.if, `${id} if`).toBeTruthy();
+ expect(evalIf(node.if, scope('alimtalk', true))).toBe(false);
+ expect(evalIf(node.if, scope('mail', undefined))).toBe(true);
+ expect(evalIf(node.if, scope('sms', false))).toBe(true);
+ });
+
+ it('클릭 URL 입력은 기존 조건(database 채널)과 게이트를 AND 로 결합한다', () => {
+ const node = findById(modal, `${PREFIX}template_click_url_input`)!;
+ expect(evalIf(node.if, scope('database', undefined))).toBe(true);
+ expect(evalIf(node.if, scope('mail', undefined))).toBe(false);
+ expect(evalIf(node.if, scope('database', true))).toBe(false);
+ });
+
+ it('변수 안내는 기존 조건(변수 보유)과 게이트를 AND 로 결합한다', () => {
+ const node = findById(modal, `${PREFIX}template_variables_info`)!;
+ const noVars = { ...scope('mail', undefined) } as Node;
+ noVars._global[STATE_KEY].definition = { variables: [] };
+ expect(evalIf(node.if, noVars)).toBe(false);
+ });
+
+ it('푸터의 코어 [미리보기]·[저장]은 게이트로 숨고 [취소]는 항상 남는다', () => {
+ const footer = findById(modal, `${PREFIX}template_modal_footer`)!;
+ const preview = footer.children[0].children[0] as Node;
+ const right = footer.children[1].children as Node[];
+ const cancel = right[0];
+ const save = right[2];
+ for (const b of [preview, save]) {
+ expect(evalIf(b.if, scope('alimtalk', true))).toBe(false);
+ expect(evalIf(b.if, scope('mail', undefined))).toBe(true);
+ }
+ expect(cancel.if).toBeUndefined();
+ });
+
+ it('수신자 규칙·유형/채널 헤더·검증 오류 블록은 게이트와 무관하게 남는다', () => {
+ for (const id of ['template_recipients_section', 'template_type_channel', 'template_validation_error']) {
+ const node = findById(modal, `${PREFIX}${id}`)!;
+ expect(node, id).toBeTruthy();
+ expect(String(node.if ?? '')).not.toContain('hidden_template_editor');
+ }
+ });
+});
diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json
index bab7fc2f..5387c06d 100644
--- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json
+++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_notification_template_edit.json
@@ -500,8 +500,7 @@
]
}
],
- "props": {
- }
+ "props": {}
}
]
}
@@ -528,6 +527,21 @@
}
]
},
+ {
+ "id": "ecommerce_template_form_sections_ext",
+ "comment": "알림 템플릿 편집 모달 본문 확장 슬롯(범용). 채널을 소유한 확장이 그 채널 전용 편집 UI(예: 외부 발송 채널의 템플릿 작성·발송 설정)를 이 자리에 주입한다. 채널 메타 hidden_template_editor=true 인 채널은 아래 코어 제목/본문 입력이 숨겨지고 이 슬롯이 본문 편집을 대신한다. 특정 확장을 알지 못하며, 미주입 시 빈 자리로 남는다.",
+ "type": "extension_point",
+ "name": "notification_template_form_sections",
+ "props": {
+ "definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}",
+ "template": "{{_global.ecommerce_notification_template_form_modal?.template}}",
+ "channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}",
+ "modalId": "modal_ecommerce_notification_template_edit",
+ "stateKey": "ecommerce_notification_template_form_modal",
+ "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}",
+ "refetchDataSourceId": "ecommerceNotificationDefinitions"
+ }
+ },
{
"id": "ecommerce_template_lang_tabs",
"type": "basic",
@@ -559,7 +573,8 @@
}
]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "ecommerce_template_subject_input",
@@ -613,7 +628,8 @@
}
]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "ecommerce_template_body_input",
@@ -668,13 +684,14 @@
}
]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
},
{
"id": "ecommerce_template_click_url_input",
"type": "basic",
"name": "Div",
- "if": "{{_global.ecommerce_notification_template_form_modal?.template?.channel === 'database'}}",
+ "if": "{{(_global.ecommerce_notification_template_form_modal?.template?.channel === 'database') && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
"children": [
{
"type": "basic",
@@ -724,7 +741,7 @@
"id": "ecommerce_template_variables_info",
"type": "basic",
"name": "Div",
- "if": "{{(_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0}}",
+ "if": "{{((_global.ecommerce_notification_template_form_modal?.definition?.variables ?? []).length > 0) && !((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}",
"props": {
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
},
@@ -841,17 +858,18 @@
"target": "modal_ecommerce_notification_definition_preview"
}
],
- "onError": [
- {
- "handler": "toast",
- "params": {
- "type": "error",
- "message": "{{error.message}}"
- }
- }
- ]
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "type": "error",
+ "message": "{{error.message}}"
+ }
+ }
+ ]
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
}
]
},
@@ -879,6 +897,21 @@
}
]
},
+ {
+ "id": "ecommerce_template_form_footer_ext",
+ "comment": "편집 모달 푸터 확장 슬롯(범용). hidden_template_editor 채널에서는 코어 [저장]/[미리보기]가 숨겨지므로, 그 채널을 소유한 확장이 자기 저장 버튼(코어 수신자 규칙 저장 포함)을 여기에 주입한다. [취소]는 코어가 유지한다.",
+ "type": "extension_point",
+ "name": "notification_template_form_footer_actions",
+ "props": {
+ "definition": "{{_global.ecommerce_notification_template_form_modal?.definition}}",
+ "template": "{{_global.ecommerce_notification_template_form_modal?.template}}",
+ "channel": "{{_global.ecommerce_notification_template_form_modal?.template?.channel ?? ''}}",
+ "modalId": "modal_ecommerce_notification_template_edit",
+ "stateKey": "ecommerce_notification_template_form_modal",
+ "saveEndpoint": "{{'/api/admin/notification-templates/' + _global.ecommerce_notification_template_form_modal?.template?.id}}",
+ "refetchDataSourceId": "ecommerceNotificationDefinitions"
+ }
+ },
{
"type": "basic",
"name": "Button",
@@ -978,7 +1011,8 @@
]
}
}
- ]
+ ],
+ "if": "{{!((availableChannels?.data?.channels ?? []).find(c => c.id === _global.ecommerce_notification_template_form_modal?.template?.channel)?.hidden_template_editor === true)}}"
}
]
}
diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json
index 9bc531cf..91ce4d59 100644
--- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json
+++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_notification_definitions.json
@@ -139,7 +139,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"
},
@@ -159,7 +159,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/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 3d732ca6..0a403434 100644
Binary files a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip and b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip differ
diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md
index aef17cfa..5f6a6480 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-24
+
+### 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/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
new file mode 100644
index 00000000..8b062986
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/CHANGELOG.md
@@ -0,0 +1,51 @@
+# Changelog
+
+이 프로젝트의 모든 주요 변경사항을 기록합니다.
+형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
+[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+
+## [1.0.0] - 2026-08-24
+
+### Added
+
+#### 문자·알림톡 발송
+
+- 비즈뿌리오 연동 문자(SMS/LMS)·카카오 알림톡 발송 플러그인 최초 릴리즈. 회원가입·주문 등 코어 알림에 자동 연결되어 메일과 같은 방식으로 발송됩니다.
+- 비즈뿌리오 계정 정보와 발신번호를 입력하는 환경설정 화면을 제공합니다.
+- 검수 환경과 운영 환경을 구분해 운영합니다. 설치 직후에는 검수 환경이 기본이며, 환경설정에서 운영 환경으로 전환하면 그때부터 실제 고객에게 발송됩니다.
+
+#### 알림톡 템플릿 작성·검수
+
+- 알림 설정의 '비즈뿌리오' 통합 탭에서 각 알림의 [편집]을 누르면, 알림 템플릿 편집 창 안에서 카카오 알림톡 템플릿을 직접 작성해 검수를 신청할 수 있습니다.
+- 승인된 템플릿은 그 내용으로 자동 발송됩니다. 승인 전에는 알림톡이 발송되지 않습니다.
+- 기본형·부가정보형·채널추가형·복합형과 강조표기·이미지·아이템리스트 등 카카오 전 유형을 지원하며, 버튼과 바로연결도 함께 등록할 수 있습니다.
+- 검수 결과는 30분 주기로 자동 확인되며, 편집 창의 [새로고침]으로 즉시 확인할 수 있습니다.
+- 검수를 신청할 때 변수 예시값처럼 검수자에게 전달할 의견을 함께 적어 보낼 수 있습니다.
+- 반려되면 사유를 같은 창에서 확인하고 수정 후 다시 신청합니다.
+- 알림 목록의 각 행에 승인 여부(승인됨 / 미승인·세부 상태)와 문자 설정 요약이 표시됩니다.
+- 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다.
+
+#### 문자(SMS) 설정
+
+- 같은 편집 창에서 알림별로 알림톡 실패 시 대체 SMS 사용 여부, SMS 단독 발송 여부, 문자 본문을 함께 설정합니다.
+- 하단 [저장] 한 번으로 알림톡 템플릿·문자 설정·수신자 규칙이 모두 저장됩니다.
+- 문자 본문은 언어별로 입력하며 회원이 사용하는 언어로 발송됩니다. 해당 언어가 비어 있으면 기본 언어로 발송됩니다.
+- [SMS 단독]을 선택한 알림은 템플릿이 승인되어 있어도 알림톡을 보내지 않고 문자로만 발송합니다.
+- 검수중·승인된 템플릿은 내용이 잠깁니다. 승인 취소는 즉시 알림톡 발송이 중단됨을 같은 창에서 확인한 뒤 진행합니다.
+
+#### 템플릿 관리
+
+- 플러그인 설정의 '알림 템플릿 관리' 화면에서 모든 알림의 템플릿 상태를 한눈에 보고 검색·필터·수정·삭제할 수 있습니다.
+- 좁은 화면에서는 목록이 카드 형태로 전환되어 모바일에서도 상태를 확인할 수 있습니다.
+
+#### 발송 결과·이력
+
+- 비즈뿌리오 webhook 으로 발송 결과를 수신해 "알림 발송 이력" 화면에 성공/실패와 사유를 기록합니다.
+- 지갑 잔액 부족 등으로 발송이 막히면 관리자에게 알립니다.
+- 요청 과다로 인한 일시적 발송 실패도 사유가 함께 표시됩니다.
+
+#### 안내
+
+- 플러그인을 삭제하면 추가했던 발송 채널이 함께 정리되고, 재설치하면 자동 복원됩니다.
+- 한국어·영어를 기본 제공하며, 일본어는 번들 언어팩으로 제공됩니다.
+- 코어 7.0.6 이상이 필요합니다.
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..17f8a1b9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/README.md
@@ -0,0 +1,267 @@
+# Bizppurio Messaging Plugin for G7
+
+
+
+
+
+
+
+
+비즈뿌리오(Bizppurio)를 연동해 문자(SMS/LMS)와 카카오 알림톡을 발송하는 G7 플러그인입니다.
+
+G7 코어 알림 시스템에 문자·알림톡 채널을 추가해, 회원가입·주문 등 코어/모듈이 발화하는 알림을 문자와 알림톡으로도 자동 발송합니다. 발송 결과는 비즈뿌리오가 보내는 webhook 통보로 수신해 성공/실패와 실패 사유를 발송 이력에 기록합니다.
+
+[주요 기능](#주요-기능) · [요구 사항](#요구-사항) · [설치](#설치) · [관리자 설정](#관리자-설정) · [webhook 등록](#webhook발송-결과-리포트-등록) · [발송 흐름](#발송-흐름) · [알림톡 템플릿 관리](#알림톡-템플릿-관리) · [발송 결과 코드](#발송-결과-코드) · [훅](#가용-훅-hook) · [API](#api) · [테스트](#테스트)
+
+---
+
+## 주요 기능
+
+- 문자(SMS/LMS) 발송 — 본문 길이에 따라 SMS/LMS 자동 선택
+- 카카오 알림톡 발송 — 승인된 템플릿의 본문·버튼·바로연결·강조표기·아이템리스트·대표링크까지 반영
+- 알림톡 미승인·발송 불가 시 문자로 자동 대체발송(옵션)
+- 회원·비회원 대상 알림에 문자 채널 연동 (비회원은 주문 시 입력한 연락처 사용)
+- 비즈뿌리오 webhook 리포트 수신으로 발송 결과(성공/실패/사유) 자동 기록
+- 검수(테스트) 모드 — 실제 발송 없이 화면·흐름 검증
+- 지갑 잔액 부족·후불 한도 초과 시 관리자 알림 (반복 발송 방지 쿨다운 적용)
+- 관리자 "알림 발송 이력" 화면에 문자·알림톡 결과 통합 표시
+- 알림별 카카오 알림톡 템플릿을 관리자 화면에서 직접 작성 → 검수 신청 → 승인 후 자동 발송
+- 검수 결과(승인·반려)를 30분 주기로 자동 확인하고, 반려 사유를 화면에서 확인해 수정 후 재신청
+
+---
+
+## 요구 사항
+
+| 구분 | 항목 | 내용 |
+|------|------|------|
+| 플랫폼 | G7 | `>= 7.0.6` |
+| 플랫폼 | PHP | `^8.2` |
+| 사전 준비 | 비즈뿌리오 계정 | 가입 + API 사용 승인 |
+| 사전 준비 | 문자 발송 | 발신번호 사전 등록 (비즈뿌리오 콘솔) |
+| 사전 준비 | 알림톡 발송 | 카카오 발신프로필 등록 (템플릿은 이 플러그인 화면에서 작성·검수 신청) |
+
+> 운영 모드로 전환하려면 비즈뿌리오 아이디·비밀번호·API 키·발신번호가 모두 입력되어야 하며, 이 시점부터 **실제 발송과 비용이 발생**합니다.
+
+---
+
+## 설치
+
+플러그인을 G7 프로젝트의 플러그인 디렉토리에 배치합니다.
+
+```text
+plugins/sirsoft-message_bizppurio
+```
+
+프론트엔드 에셋을 수정한 경우 플러그인 디렉토리에서 빌드합니다.
+
+```bash
+npm install
+npm run build
+```
+
+그다음 G7 관리자에서 플러그인을 활성화합니다. 설치 시 회원 대상 알림의 문자·알림톡 채널이 알림 설정에 등록됩니다.
+
+---
+
+## 관리자 설정
+
+관리자 플러그인 설정 화면에서 비즈뿌리오 계정 정보를 입력합니다.
+
+| 설정 | 필수 여부 | 설명 |
+|------|:---:|------|
+| 검수 모드 | - | 활성화 시 실제 발송 없이 검수용으로만 동작. 발송 이력에 "검수" 라벨로 표시되어 실제 장애와 구분됨 |
+| 비즈뿌리오 아이디 / 비밀번호 | 운영 시 필수 | 비즈뿌리오 계정 로그인 정보 |
+| API 키 | 운영 시 필수 | 비즈뿌리오 API 인증에 사용 |
+| 발신번호 | 운영 시 필수 | 문자 발송용 발신번호. 비즈뿌리오 콘솔에 사전 등록된 번호만 사용 가능 |
+| 알림톡 발신프로필 키 | 알림톡 사용 시 필수 | 카카오 알림톡 발송에 사용할 발신프로필 키 |
+| 잔액부족 알림 재발송 간격(초) | 선택 (기본 3600) | 잔액 부족/한도 초과 실패 시 관리자 알림의 최소 재발송 간격. 대량 실패 시 반복 발송 방지 |
+
+> 검수와 운영은 별도의 비즈뿌리오 계정으로 운영하는 것을 권장합니다. 비밀번호·API 키·발신프로필 키는 관리자 설정 화면에서만 입력하며 프론트엔드로 노출되지 않습니다.
+
+---
+
+## webhook(발송 결과 리포트) 등록
+
+비즈뿌리오가 발송 결과를 통보할 URL을 비즈뿌리오 콘솔에 등록해야 발송 결과(성공/실패/사유)가 발송 이력에 자동 기록됩니다.
+
+> **이 등록을 하지 않으면 발송 자체는 되지만 성공/실패 여부를 확인할 수 없습니다.**
+
+```text
+https://your-domain.com/api/plugins/sirsoft-message_bizppurio/webhook
+```
+
+정확한 URL은 관리자 플러그인 설정 화면의 환경설정 탭에서도 복사할 수 있습니다.
+
+---
+
+## 발송 흐름
+
+```text
+코어/모듈이 알림 발화 (예: 회원가입, 주문 완료)
+→ 알림 설정에서 켜 둔 문자·알림톡 채널로 발송 작업 큐잉
+→ 알림톡: 승인된 템플릿의 저장 내용으로 발송, 발송 불가 시 문자로 대체발송(옵션)
+→ 문자: 본문 길이에 따라 SMS/LMS 자동 선택
+→ 비즈뿌리오 발송 API 호출
+→ 비즈뿌리오가 webhook 으로 결과(성공/실패/사유) 통보
+→ 발송 이력에 결과 기록, 실패 시 사유·결과코드 함께 기록
+```
+
+일시적 오류(카카오 시스템 오류, 처리 지연, 게이트웨이 오류 등)로 실패한 경우 자동으로 재시도합니다. 지갑 잔액 부족·후불 한도 초과는 재시도 대상이 아니며, 즉시 실패 처리와 함께 관리자에게 알림이 발송됩니다.
+
+---
+
+## 알림톡 템플릿 관리
+
+카카오 알림톡 템플릿의 **작성·검수 신청·취소·삭제를 관리자 화면에서 직접** 수행합니다. 비즈뿌리오 콘솔을 따로 열 필요가 없습니다.
+
+작성 위치는 두 곳입니다.
+
+- **알림 설정 > 비즈뿌리오 탭** — 알림 항목의 **[편집]** 을 누르면 알림 템플릿 편집 창 안에 **알림톡 템플릿** 섹션과 **문자(SMS)** 섹션이 함께 열립니다. 알림톡 본문·유형·버튼, 대체 SMS/SMS 단독 여부와 문자 본문, 수신자 규칙을 한 창에서 고치고 하단 **[저장]** 한 번으로 모두 저장합니다(알림톡 검수는 **[저장 후 검수 신청]** — 작성 폼 하단의 **검수자 전달 의견**에 변수 예시값 등을 적으면 신청과 함께 카카오 검수자에게 전달됩니다). 이 채널에서는 코어의 제목/본문 입력이 숨겨지고 알림톡 본문이 그 자리를 대신합니다. 알림 목록의 각 행 하단에는 승인 여부(**승인됨** / **미승인 (세부 상태)**)와 문자 설정 요약이 표시됩니다. 게시판·이커머스의 알림 설정 화면에서도 동일하게 동작합니다.
+- **플러그인 설정 > 알림 템플릿 관리** — 전체 알림의 템플릿 상태를 한 화면에서 보고 검색·필터링합니다(자체 작성/SMS 본문 모달).
+
+기본 흐름은 **작성 → 검수 신청 → (카카오 승인) → 자동 발송** 입니다. 승인된 시점의 내용이 발송 기준으로 저장되며, 발송할 때마다 카카오를 조회하지 않습니다.
+
+메시지 유형(기본형·부가정보형·채널추가형·복합형)과 강조 유형(없음·강조표기·이미지·아이템리스트), 버튼(최대 5개)·바로연결(최대 10개)을 지원합니다. 본문에는 `#{변수}` 형식으로 알림 변수를 넣을 수 있습니다.
+
+이미지 강조 유형에 쓸 이미지는 화면에서 직접 업로드하며, 카카오 규격에 따라 **jpg/png · 500KB 이하 · 가로 500px 이상 · 가로:세로 2:1** 비율이어야 합니다. 규격에 맞지 않으면 업로드 단계에서 사유와 함께 거부됩니다.
+
+| 상태 | 발송 가능 | 설명 |
+|:---:|:---:|------|
+| 미작성 | ❌ | 템플릿 내용을 아직 작성하지 않은 상태 |
+| 작성중 | ❌ | 저장했으나 검수를 신청하기 전 상태 (내용 수정 가능) |
+| 검수중 | ❌ | 카카오 검수 진행 중. 내용을 고치려면 먼저 [신청 취소] |
+| 승인 | ✅ | 검수 승인 완료 — 이 알림이 발생하면 알림톡이 발송됩니다 |
+| 반려 | ❌ | 카카오 검수 반려. 화면에서 사유를 확인하고 수정 후 재신청 |
+| 중지 | ❌ | 카카오에서 사용 중지된 템플릿 |
+| 차단 | ❌ | 카카오에서 차단된 템플릿 |
+| 휴면 | ❌ | 장기 미사용으로 휴면 전환. 화면의 [휴면 해제]로 복구 |
+
+검수 결과는 30분 주기로 자동 확인하며, 편집 창의 [새로고침]으로 즉시 확인할 수도 있습니다.
+
+검수중·승인된 템플릿은 편집 창에서 내용이 잠기고 요약만 표시됩니다. 검수중이면 [신청 취소] 후, 승인된 템플릿은 [수정 (승인 취소)] 를 눌러 승인을 먼저 취소해야 고칠 수 있습니다. **승인을 취소하면 그 즉시 해당 알림의 알림톡 발송이 중단**되므로(대체 SMS 는 설정에 따라 계속 발송), 같은 창의 확인 박스에서 확인 후 진행합니다.
+
+### 문자(SMS) 본문
+
+알림마다 문자 본문을 따로 입력합니다(편집 창의 문자(SMS) 섹션). 쓰임은 두 가지이고 본문은 하나를 공유하며, 카카오 검수 대상이 아니므로 [저장] 즉시 반영됩니다.
+
+- **대체 SMS** — 알림톡 발송이 실패했을 때(수신 거부·미가입 등) 같은 번호로 문자를 대신 보냅니다.
+- **SMS 단독** — 알림톡을 쓰지 않고 문자로만 보냅니다. 이 항목을 켜면 템플릿이 승인 상태여도 알림톡을 보내지 않습니다.
+
+문자 본문은 **언어별로 입력**하며, 회원이 사용하는 언어의 본문으로 발송됩니다. 해당 언어의 본문이 비어 있으면 기본 언어 본문으로 발송하고, 기본 언어도 비어 있으면 그 알림의 문자 발송을 건너뜁니다. 알림톡 본문은 카카오가 승인한 원문 그대로만 발송할 수 있어 언어 구분이 없습니다.
+
+---
+
+## 발송 결과 코드
+
+비즈뿌리오/카카오가 반환하는 결과 코드는 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) | 알림톡 카테고리·발신프로필 조회 |
+| [token.md](docs/api/token.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` | 알림톡 템플릿 작성·검수 신청·신청/승인 취소·상태 동기화·삭제, 대체 SMS 설정 등 관리 작업 |
+
+---
+
+## 삭제 시 동작
+
+플러그인을 삭제하면 이 플러그인이 알림 설정에 추가했던 문자·알림톡 채널이 함께 정리됩니다. 메일·사이트 내 알림 등 다른 채널과 알림 자체는 그대로 유지되며, 플러그인을 다시 설치하면 문자·알림톡 채널이 자동으로 복원됩니다.
+
+---
+
+## 보안 및 운영 참고
+
+- 비즈뿌리오 비밀번호, 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_templates_table.php b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_templates_table.php
new file mode 100644
index 00000000..441d4a7d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/database/migrations/2026_07_13_000002_create_bizppurio_templates_table.php
@@ -0,0 +1,68 @@
+bigIncrements('id')->comment('템플릿 PK');
+ $table->string('notification_type', 100)->comment('코어 notification_definitions.type (알림 1건당 1행)');
+ $table->boolean('alimtalk_enabled')->default(false)->comment('알림톡 발송 사용 여부');
+ $table->string('template_code', 30)->nullable()->comment('자체 채번한 카카오 템플릿 코드 (등록 전 null)');
+ $table->string('sender_key', 100)->nullable()->comment('신청 당시 발신프로필 키 스냅샷');
+ $table->json('content')->nullable()->comment('카카오 등록 페이로드 (templateName/templateMessageType/templateEmphasizeType/templateContent/buttons 등 kapi add 필드 그대로 — 유형별 컬럼 분해 없이 JSON 단일 컬럼)');
+ $table->json('approved_content')->nullable()->comment('승인 확정 시점 content 스냅샷 (발송 SSoT — 승인 취소돼도 유지)');
+ $table->string('status', 20)->default('draft')->comment('검수 상태: draft/requested/approved/rejected/stopped/blocked/dormant (BizppurioTemplateStatus enum)');
+ $table->json('inspection_detail')->nullable()->comment('kapi detail 의 comments 배열 스냅샷 (반려 사유 원문)');
+ $table->timestamp('requested_at')->nullable()->comment('검수 신청 시각');
+ $table->timestamp('approved_at')->nullable()->comment('승인 확정 시각');
+ $table->timestamp('last_synced_at')->nullable()->comment('마지막 카카오 상태 동기화 시각');
+ $table->boolean('fallback_sms_enabled')->default(false)->comment('알림톡 실패 시 SMS 대체발송 여부 (resend 위임)');
+ $table->json('sms_body')->nullable()->comment('대체 SMS 겸 SMS 단독 본문의 다국어 맵 ({"ko": "...", "en": "..."}, #{var} 치환) — 수신자 로케일로 렌더');
+ $table->boolean('sms_only')->default(false)->comment('알림톡 없이 SMS 만 발송하는 알림 여부 (표시·알림톡 게이트용)');
+ $table->boolean('is_active')->default(true)->comment('활성 여부');
+ $table->timestamps();
+
+ $table->unique('notification_type', 'bizppurio_template_type_unique');
+ $table->index('status', 'bizppurio_template_status_idx');
+ });
+
+ if (DB::getDriverName() === 'mysql') {
+ Schema::table('bizppurio_templates', function (Blueprint $table) {
+ $table->comment('비즈뿌리오 알림 템플릿 (알림톡 등록·검수 상태 + SMS 본문)');
+ });
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('bizppurio_templates');
+ }
+};
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..2642b4a2
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/dist/js/plugin.iife.js
@@ -0,0 +1 @@
+var SirsoftMessageBizppurio=function($){"use strict";const I=window.G7Core?.createLogger?.("MessageBizppurio:InsertVariable")??{log:(...e)=>console.log("[MessageBizppurio:InsertVariable]",...e),warn:(...e)=>console.warn("[MessageBizppurio:InsertVariable]",...e),error:(...e)=>console.error("[MessageBizppurio:InsertVariable]",...e)},D="bz_template_content",B="bz_tpl_modal.content.templateContent";function E(e,n){const t=window.G7Core,r=e.params??{},o=String(r.variable??"").trim(),i=String(r.target??D),l=String(r.stateTarget??"global"),a=String(r.statePath??B);if(o===""){I.warn("삽입할 변수명이 지정되지 않았습니다.");return}const c=l==="local"?t?.state?.setLocal:t?.state?.setGlobal??t?.state?.set,S=l==="local"?t?.state?.getLocal:t?.state?.getGlobal??t?.state?.get;if(!c||!S){I.error("G7Core.state API 를 사용할 수 없습니다.");return}const m=l==="local"?n?.state??S()??{}:S()??{},s=String(C(m,a)??""),d=`#{${o}}`,u=document.querySelector(`textarea[name="${i}"], input[name="${i}"]`),g=(u?.value??"")===s,h=g?u?.selectionStart??s.length:s.length,_=g?u?.selectionEnd??s.length:s.length,N=s.slice(0,h)+d+s.slice(_);if(c({[a]:N}),u){const M=h+d.length;requestAnimationFrame(()=>{try{u.focus(),u.setSelectionRange(M,M)}catch{}})}I.log(`변수 삽입: ${d} → ${l}:${a}`)}function C(e,n){return n.split(".").reduce((t,r)=>t?.[r],e)}const f=window.G7Core?.createLogger?.("MessageBizppurio:UploadImage")??{log:(...e)=>console.log("[MessageBizppurio:UploadImage]",...e),warn:(...e)=>console.warn("[MessageBizppurio:UploadImage]",...e),error:(...e)=>console.error("[MessageBizppurio:UploadImage]",...e)},O="/api/plugins/sirsoft-message_bizppurio/admin/templates/image",L="sirsoft-message_bizppurio.template.form.image_upload_failed";function j(){if(typeof window>"u")return{};const e=localStorage.getItem("g7_locale");return e?{"Accept-Language":e}:{}}function z(e){const n=e?.t?.(L);return typeof n=="string"&&n!==L?n:"Failed to upload the image."}function k(e,n){return n==="local"?e?.state?.setLocal??null:e?.state?.setGlobal??e?.state?.set??null}function F(e,n){return n==="local"?e?.state?.getLocal??null:e?.state?.getGlobal??e?.state?.get??null}function V(e,n){return n.split(".").reduce((t,r)=>t?.[r],e)}function w(e,n,t){const r=F(e,n);if(!r)return!0;try{return V(r()??{},`${t}.uploading`)===!0}catch{return!0}}let P=!1;async function H(e,n){const t=window.G7Core,r=e.params??{},o=String(r.stateTarget??"global"),i=String(r.statePathUrl??"bz_tpl_modal.content.templateImageUrl"),l=String(r.statePathName??"bz_tpl_modal.content.templateImageName"),a=String(r.statePathStatus??"bz_tpl_upload"),c=k(t,o);if(!c){f.error("G7Core.state setter 를 사용할 수 없습니다.");return}const m=(n?.event??e?.event)?.target,s=m?.files?.[0];if(s){if(P){f.warn("이미 업로드가 진행 중입니다 — 새 파일 선택을 무시합니다."),m&&(m.value="");return}P=!0,c({[`${a}.uploading`]:!0,[`${a}.error`]:null});try{const d=localStorage.getItem("auth_token")??"",u=new FormData;u.append("image",s,s.name);const v=await fetch(O,{method:"POST",headers:{Accept:"application/json",...d?{Authorization:`Bearer ${d}`}:{},...j()},body:u}),g=await v.json().catch(()=>({}));if(!v.ok||g?.success===!1){const _=g?.errors?.bizppurio_message||g?.errors?.image?.[0]||g?.message||z(t);w(t,o,a)&&y(c,a,i,l,_),f.warn("이미지 업로드 실패:",_);return}const h=String(g?.data?.url??"").trim();if(h===""){w(t,o,a)&&y(c,a,i,l,z(t)),f.warn("이미지 업로드 응답에 url 이 없습니다:",g);return}if(!w(t,o,a)){f.warn("모달이 다시 시딩되어 업로드 결과를 버립니다:",h);return}c({[`${a}.uploading`]:!1,[`${a}.error`]:null,[i]:h,[l]:s.name}),f.log("이미지 업로드 완료:",h)}catch(d){w(t,o,a)&&y(c,a,i,l,z(t)),f.error("이미지 업로드 예외:",d)}finally{P=!1,m&&(m.value="")}}}function y(e,n,t,r,o){e({[`${n}.uploading`]:!1,[`${n}.error`]:o,[t]:"",[r]:""})}const b={insertVariable:E,uploadTemplateImage:H},p="sirsoft-message_bizppurio",T=window.G7Core?.createLogger?.(`Plugin:${p}`)??{log:(...e)=>console.log(`[Plugin:${p}]`,...e),warn:(...e)=>console.warn(`[Plugin:${p}]`,...e),error:(...e)=>console.error(`[Plugin:${p}]`,...e)};function G(e){Object.entries(b).forEach(([n,t])=>{e.registerHandler(`${p}.${n}`,t,{category:"plugin",source:p})}),T.log(`${Object.keys(b).length} handler(s) registered:`,Object.keys(b).map(n=>`${p}.${n}`))}function U(e){const n=window.G7Core?.getActionDispatcher?.();if(n){G(n);return}if(!e){T.warn("ActionDispatcher 를 찾지 못해 핸들러를 등록하지 못했습니다.");return}let t=0;const r=50,o=()=>{const i=window.G7Core?.getActionDispatcher?.();if(i){G(i);return}++t<=r?setTimeout(o,100):T.error("ActionDispatcher 를 찾지 못해 핸들러 등록에 실패했습니다.")};o()}function A(){if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>U(!0));else{const e=!!window.G7Core?.getActionDispatcher?.();U(!e)}}return A(),typeof window<"u"&&(window.__SirsoftMessageBizppurio={identifier:p,handlers:Object.keys(b),initPlugin:A}),$.initPlugin=A,Object.defineProperty($,Symbol.toStringTag,{value:"Module"}),$}({});
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..280895ca
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md
@@ -0,0 +1,19 @@
+# API 레퍼런스 문서 목차
+
+> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반).
+> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
+
+
+- **문서 수**: 6 · **엔드포인트 수**: 21
+
+| 문서 | 도메인 | 엔드포인트 |
+| --- | --- | --- |
+| [alimtalk-templates.md](alimtalk-templates.md) | `alimtalk-templates` | 2 |
+| [dispatch-results.md](dispatch-results.md) | `dispatch-results` | 2 |
+| [report.md](report.md) | `report` | 1 |
+| [templates.md](templates.md) | `templates` | 14 |
+| [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..16bfe23a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/alimtalk-templates.md
@@ -0,0 +1,131 @@
+# Alimtalk Templates API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 알림톡 템플릿 작성 모달의 참조 조회(카테고리·발신프로필) 엔드포인트 레퍼런스입니다
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시(curl) + 실측 응답 필드 표 + 응답 예시(envelope)
+3. 실시간 템플릿 목록/상세 화면(구 Phase 5)은 DB 기반 라이프사이클(templates.md)로 대체되어 제거됐습니다
+4. kapi 실패는 422 + errors.bizppurio_message(카카오 사유 원문) 규약을 따릅니다
+5. 갱신: 코드 변경 후 php artisan api:docgen 재실행
+```
+
+---
+
+작성 모달 참조 조회(카테고리·발신프로필) 전용 문서다. 알림톡 템플릿 작성 모달의 카테고리 셀렉트·발신프로필
+셀렉트가 소비하는 kapi(카카오 관리 API) 조회를 프록시한다. 템플릿의 등록·검수·승인 라이프사이클 API 는
+[templates.md](templates.md) 를 참조.
+
+
+### 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` 내부)
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| categories | array | `[{...}]` | kapi `/v3/kakao/template/category/all` 조회 결과 배열. 각 항목: `code`(카테고리 코드 — `content.categoryCode` 에 사용), `name`(소분류명), `groupName`(대분류명) |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "성공적으로 처리되었습니다.",
+ "data": {
+ "categories": [
+ { "code": "001001", "name": "회원가입", "groupName": "회원" },
+ { "code": "004001", "name": "구매완료", "groupName": "구매" }
+ ]
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+템플릿 등록에 사용할 카카오 카테고리 전체(대분류·소분류)를 조회한다(`data.categories`). 작성 모달의
+카테고리 셀렉트가 소비하며, 선택된 소분류 코드가 템플릿 생성/수정의 `content.categoryCode` 값이 된다.
+kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다.
+
+
+### 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` 내부)
+
+| 필드 | 타입 | 예시값 | 용도/설명 |
+| --- | --- | --- | --- |
+| profiles | array | `[{...}]` | kapi `/v3/kakao/profile/use` 응답 `data.success` 배열(조회 실패 프로필 `fail` 은 제외). 각 항목: `senderKey`(발신프로필 키), `name`(프로필명), `status`, `block`, `dormant`, `categoryCode` 등 kapi 원형 필드 |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "성공적으로 처리되었습니다.",
+ "data": {
+ "profiles": [
+ { "senderKey": "05aa099bcbc5220a8c0b2...", "name": "@우리상점", "status": "A", "block": false, "dormant": false }
+ ]
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+
+
+**설명**
+
+발신프로필(사용중) 상태 정보를 조회한다(`data.profiles`). 작성 모달의 발신프로필 셀렉트·상태 표시가
+소비한다. kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다.
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..24b69d66
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/dispatch-results.md
@@ -0,0 +1,334 @@
+# 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 | `[]` | 요청한 `notification_log_ids` 각각의 비즈뿌리오 발송 결과. 해당 알림 로그로 발송된 건이 없으면 빈 배열 |
+
+**응답 예시**
+
+```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…` | `notification_log_id` 를 키로 하는 결과 맵. 각 값은 `status`(pending/sent/failed) · `status_label`(현재 로케일 라벨) · `result_code`(비즈뿌리오 결과 코드) · 실패 사유를 담는다 |
+
+**응답 예시**
+
+```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`)이 없는 경우 |
+
+
+
+**설명**
+
+알림 발송 이력 화면이 최근 발송 건의 비즈뿌리오 결과를 한 번에 받아 결과 컬럼에 주입할 때 사용한다.
+`lookup` 이 지정한 로그 ID 만 조회하는 것과 달리, 이 엔드포인트는 최근 구간을 서버가 정해 돌려주므로
+화면이 조회 대상을 미리 알 필요가 없다. 결과는 `notification_log_id` 를 키로 하는 맵이며,
+비즈뿌리오로 발송되지 않은 알림은 키 자체가 없다 — 화면은 키 부재를 "해당 없음" 으로 그린다.
+
+
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..1ebe76ef
--- /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 | `https://api.example.com/api/plugins/sirsoft-message_bizppurio/webhook` | 비즈뿌리오 콘솔에 등록할 발송 결과 리포트 수신 URL (현재 사이트 기준 절대 URL) |
+
+**응답 예시**
+
+```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..e957e91a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/templates.md
@@ -0,0 +1,1064 @@
+# Templates API 레퍼런스
+
+> **소유**: plugin `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반). @generated 블록은 재생성 시 갱신되며, 사람이 작성한 설명은 보존됩니다.
+
+---
+
+## TL;DR (5초 요약)
+
+```text
+1. 이 문서는 Templates 엔드포인트 레퍼런스입니다 (readiness 1건은 실측 @generated, 라이프사이클 13건은 코드 사양 기반 수기 작성)
+2. 각 엔드포인트: 메서드/URI/권한 + 요청 파라미터 표 + 요청 예시 + 응답 필드 표 + 응답 예시(envelope)
+3. 알림 템플릿 라이프사이클: 등록(draft) → 검수 신청(requested) → 승인(approved) 후 발송 활성화 — 발송 판정은 DB 가 유일한 근거
+4. kapi(카카오 관리 API) 실패는 422 + errors.bizppurio_message(사유 원문)·errors.result_code / 상태 전이 위반도 422
+5. 갱신: 코드 변경 후 이 문서를 수기 동기화 (라이프사이클 항목은 kapi 자격증명 없이 실측 불가)
+```
+
+---
+
+
+### 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` | 카카오 관리 API 키(`api_key`) 저장 여부 — 값 자체는 sensitive 라 노출하지 않고 저장 여부만 내려준다 |
+| sender_key_set | boolean | `false` | 알림톡 발신프로필 키(`sender_key`) 저장 여부 |
+| ready | boolean | `false` | 두 값이 모두 저장되어 템플릿 작성·검수 신청이 가능한 상태인지 (알림 설정 탭 readiness 배너가 소비) |
+
+**응답 예시**
+
+```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`)이 없는 경우 |
+
+
+
+**설명**
+
+카카오 관리에 필요한 자격증명(`api_key`·`sender_key`)은 sensitive 설정이라 코어 설정 조회 응답에서 제거된다.
+값 자체는 노출하지 않고 저장 여부(boolean)만 내려주며, 알림톡 탭 readiness 배너가 소비한다.
+
+---
+
+## 알림 템플릿 라이프사이클
+
+> 아래 13개 엔드포인트는 kapi(카카오 관리 API) 자격증명 없이는 실측할 수 없어 `api:docgen` 실측 블록 대신
+> 코드 사양(컨트롤러 직렬화·FormRequest 검증·서비스 상태 전이) 기반으로 수기 작성했다. 응답 예시값은 합리적 예시다.
+
+시스템 등록(**draft**) → 검수 신청(**requested**) → 승인(**approved**) 후 알림톡 발송이 활성화된다.
+발송 판정은 DB(`bizppurio_templates`)가 유일한 근거이며(실시간 조회 폐지), 카카오와의 상태 정합은
+스케줄러(`bizppurio:sync-template-status`)와 수동 sync 가 유지한다.
+
+- **인증**: 전 엔드포인트 `auth:sanctum` + 관리자
+- **권한**: 조회(목록/맵/상세) = `permission:sirsoft-message_bizppurio.messaging.view` / 그 외 변경 = `permission:sirsoft-message_bizppurio.messaging.manage`
+- **status 어휘**: `draft`(작성중) / `requested`(검수중) / `approved`(승인됨) / `rejected`(반려됨) / `stopped`(발송중지) / `blocked`(차단됨) / `dormant`(휴면)
+
+**공통 실패 규약**
+
+| 상태코드 | 발생 조건 | 응답 형태 |
+| --- | --- | --- |
+| 422 (kapi 실패) | 카카오 관리 API 호출이 실패한 경우 | `errors.bizppurio_message` 에 카카오가 준 실패 사유 원문, `errors.result_code` 에 결과 코드 (관리자 전용 면 — 조치 근거 제공) |
+| 422 (상태 전이 위반) | 현재 상태에서 허용되지 않는 액션인 경우 | 위반 사유 메시지 (예: "현재 상태(requested)에서는 알림톡 내용을 수정할 수 없습니다. …") |
+| 422 (검증 실패) | 요청 파라미터가 검증 규칙을 위반한 경우 | `error.errors` 에 필드별 메시지 |
+| 404 | `{id}` 에 해당하는 템플릿이 없는 경우 | `messages.not_found` |
+| 401 / 403 | 토큰 없음·만료 / 요구 권한 없음 | 표준 |
+
+kapi 실패 422 응답 예시:
+
+```json
+{
+ "success": false,
+ "message": "카카오 요청이 실패했습니다.",
+ "error": {
+ "errors": {
+ "bizppurio_message": "이미 사용중인 템플릿 코드입니다.",
+ "result_code": "504"
+ }
+ }
+}
+```
+
+인프라 장애·코드 결함은 4xx 로 위장하지 않는다 — 도메인 예외(kapi 실패·상태 전이 위반) 외의 예외는 500 으로 드러난다.
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/templates
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.index`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@index`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| status | query | string | 아니오 | `draft`/`requested`/`approved`/`rejected`/`stopped`/`blocked`/`dormant` | 우리 상태 enum 필터 |
+| search | query | string | 아니오 | max 100 | 알림 유형·알림 정의 이름 검색어 |
+| page | query | integer | 아니오 | min 1 | 페이지 번호 (기본 1) |
+| per_page | query | integer | 아니오 | min 1 · max 100 | 페이지 크기 (기본 20) |
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/templates?status=approved&page=1&per_page=20 HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data.templates[]` — 목록 행. 대형 JSON(content 등)은 제외되고 존재 여부(has_content)만 실림)
+
+| 필드 | 타입 | 용도/설명 |
+| --- | --- | --- |
+| id | integer | 템플릿 PK |
+| notification_type | string | 코어 알림 정의 type (`notification_definitions.type`) |
+| definition_name | object\|null | 조인된 알림 정의 다국어 이름 (예: `{"ko": "...", "en": "..."}`) |
+| definition_variables | array | 알림 정의가 제공하는 치환 변수 목록 |
+| definition_extension_type | string\|null | 알림 정의 소유 확장 유형 (`core`/`module`/`plugin`) |
+| definition_extension_identifier | string\|null | 알림 정의 소유 확장 식별자 |
+| alimtalk_enabled | boolean | 알림톡 사용 여부 |
+| template_code | string\|null | 자체 채번된 카카오 템플릿 코드 (검수 신청 전이면 null) |
+| status | string | 상태 (status 어휘 참조) |
+| has_content | boolean | 알림톡 content(카카오 등록 페이로드) 작성 여부 |
+| requested_at | string\|null | 검수 신청 시각 (ISO 8601) |
+| approved_at | string\|null | 승인 시각 (ISO 8601) |
+| last_synced_at | string\|null | 마지막 카카오 동기화 시각 (ISO 8601) |
+| fallback_sms_enabled | boolean | 알림톡 실패 시 SMS 대체발송 여부 |
+| sms_only | boolean | SMS 단독 발송 여부 (알림톡 미사용) |
+| is_active | boolean | 발송 활성 여부 |
+
+**응답 필드** (`data.pagination`)
+
+| 필드 | 타입 | 용도/설명 |
+| --- | --- | --- |
+| total | integer | 총 건수 |
+| last_page | integer | 마지막 페이지 번호 |
+| current_page | integer | 현재 페이지 번호 |
+| per_page | integer | 페이지 크기 |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "messages.success",
+ "data": {
+ "templates": [
+ {
+ "id": 3,
+ "notification_type": "order_completed",
+ "definition_name": { "ko": "주문 완료", "en": "Order completed" },
+ "definition_variables": ["user_name", "order_id"],
+ "definition_extension_type": "module",
+ "definition_extension_identifier": "sirsoft-ecommerce",
+ "alimtalk_enabled": true,
+ "template_code": "g7_1a2b3c4d_1",
+ "status": "approved",
+ "has_content": true,
+ "requested_at": "2026-08-10T09:12:00+09:00",
+ "approved_at": "2026-08-12T14:30:00+09:00",
+ "last_synced_at": "2026-08-19T03:00:00+09:00",
+ "fallback_sms_enabled": true,
+ "sms_only": false,
+ "is_active": true
+ }
+ ],
+ "pagination": {
+ "total": 1,
+ "last_page": 1,
+ "current_page": 1,
+ "per_page": 20
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+| 422 | Unprocessable Entity | 요청 파라미터가 검증 규칙을 위반한 경우 |
+
+**설명**
+
+템플릿 관리 화면의 DB 목록 조회다. 알림 정의(`notification_definitions`)를 조인해 라벨(definition_name)과
+소속 확장을 함께 내려주며, 대형 JSON(content·approved_content·inspection_detail)은 목록에서 제외된다 —
+상세는 `GET /admin/templates/{id}` 로 조회한다.
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/templates/map
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.map`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@map`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+_요청 파라미터 없음._
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/templates/map HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data.templates` — 키는 `notification_type`, 값은 요약 객체)
+
+| 필드 | 타입 | 용도/설명 |
+| --- | --- | --- |
+| id | integer | 템플릿 PK |
+| notification_type | string | 코어 알림 정의 type |
+| alimtalk_enabled | boolean | 알림톡 사용 여부 |
+| template_code | string\|null | 카카오 템플릿 코드 (검수 신청 전이면 null) |
+| status | string | 상태 (status 어휘 참조) |
+| has_content | boolean | 알림톡 content 작성 여부 |
+| requested_at | string\|null | 검수 신청 시각 (ISO 8601) |
+| approved_at | string\|null | 승인 시각 (ISO 8601) |
+| last_synced_at | string\|null | 마지막 동기화 시각 (ISO 8601) |
+| inspection_detail | array\|null | 반려 사유(comments) 등 검수 상세 |
+| fallback_sms_enabled | boolean | SMS 대체발송 여부 |
+| sms_body | object\|null | 대체/단독 SMS 본문의 로케일 맵 (`{"ko": "...", "en": "..."}`) |
+| sms_only | boolean | SMS 단독 발송 여부 |
+| is_active | boolean | 발송 활성 여부 |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "messages.success",
+ "data": {
+ "templates": {
+ "order_completed": {
+ "id": 3,
+ "notification_type": "order_completed",
+ "alimtalk_enabled": true,
+ "template_code": "g7_1a2b3c4d_1",
+ "status": "approved",
+ "has_content": true,
+ "requested_at": "2026-08-10T09:12:00+09:00",
+ "approved_at": "2026-08-12T14:30:00+09:00",
+ "last_synced_at": "2026-08-19T03:00:00+09:00",
+ "inspection_detail": null,
+ "fallback_sms_enabled": true,
+ "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."},
+ "sms_only": false,
+ "is_active": true
+ }
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+
+**설명**
+
+알림 설정 3면(코어/게시판/이커머스)의 행 하단 UI 가 소비하는 `notification_type` 키 요약 맵이다.
+행 수는 알림 정의 수에 묶인 설정성 데이터라 페이지네이션 없이 전량 내려준다.
+
+
+### GET /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.show`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@show`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+
+**요청 예시**
+
+```http
+GET /api/plugins/sirsoft-message_bizppurio/admin/templates/3 HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data.template` — 상세 객체. 변경 계열 엔드포인트들도 동일 형태를 반환한다)
+
+| 필드 | 타입 | 용도/설명 |
+| --- | --- | --- |
+| id | integer | 템플릿 PK |
+| notification_type | string | 코어 알림 정의 type |
+| alimtalk_enabled | boolean | 알림톡 사용 여부 |
+| template_code | string\|null | 자체 채번된 카카오 템플릿 코드 |
+| sender_key | string\|null | 검수 신청 시점의 발신프로필 키 스냅샷 |
+| content | object\|null | 카카오 등록 페이로드 (작성중 내용 — kapi add/update 에 그대로 사용) |
+| approved_content | object\|null | 승인 시점에 동결된 content 스냅샷 (발송 SSoT) |
+| status | string | 상태 (status 어휘 참조) |
+| is_approved | boolean | `status === approved` 편의 플래그 |
+| inspection_detail | array\|null | 반려 사유(comments) 등 검수 상세 |
+| requested_at | string\|null | 검수 신청 시각 (ISO 8601) |
+| approved_at | string\|null | 승인 시각 (ISO 8601) |
+| last_synced_at | string\|null | 마지막 동기화 시각 (ISO 8601) |
+| fallback_sms_enabled | boolean | SMS 대체발송 여부 |
+| sms_body | object\|null | 대체/단독 SMS 본문의 로케일 맵 (`{"ko": "...", "en": "..."}`) |
+| sms_only | boolean | SMS 단독 발송 여부 |
+| is_active | boolean | 발송 활성 여부 |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "messages.success",
+ "data": {
+ "template": {
+ "id": 3,
+ "notification_type": "order_completed",
+ "alimtalk_enabled": true,
+ "template_code": "g7_1a2b3c4d_1",
+ "sender_key": "b4c9...{MASKED}",
+ "content": {
+ "templateName": "주문 완료 안내",
+ "templateMessageType": "BA",
+ "templateEmphasizeType": "NONE",
+ "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.",
+ "categoryCode": "004001",
+ "buttons": [
+ { "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" }
+ ]
+ },
+ "approved_content": {
+ "templateName": "주문 완료 안내",
+ "templateMessageType": "BA",
+ "templateEmphasizeType": "NONE",
+ "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.",
+ "categoryCode": "004001",
+ "buttons": [
+ { "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" }
+ ]
+ },
+ "status": "approved",
+ "is_approved": true,
+ "inspection_detail": null,
+ "requested_at": "2026-08-10T09:12:00+09:00",
+ "approved_at": "2026-08-12T14:30:00+09:00",
+ "last_synced_at": "2026-08-19T03:00:00+09:00",
+ "fallback_sms_enabled": true,
+ "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."},
+ "sms_only": false,
+ "is_active": true
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.view`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+
+**설명**
+
+작성 모달·상세 패널이 소비하는 단건 상세다. `content`(작성중)와 `approved_content`(승인 동결 스냅샷)가
+분리되어 있어, 승인 후 content 를 다시 고쳐도 발송은 `approved_content` 기준으로 유지된다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/templates
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.store`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@store`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| notification_type | body | string | 예 | max 100 · 알림 정의 존재(exists) · 미등록(unique) | 대상 코어 알림 정의 type (알림 1건당 1행) |
+| alimtalk_enabled | body | boolean | 아니오 | — | 알림톡 사용 여부 |
+| fallback_sms_enabled | body | boolean | 아니오 | — | SMS 대체발송 여부 |
+| sms_body | body | object | 아니오 | nullable · 로케일별 max 2000 | 대체/단독 SMS 본문의 로케일 맵. 키는 시스템 지원 로케일(`app.translatable_locales`), 값은 각 언어 본문 |
+| sms_only | body | boolean | 아니오 | — | SMS 단독 발송 여부 |
+| is_active | body | boolean | 아니오 | — | 발송 활성 여부 |
+| content | body | object | 아니오 | nullable | 카카오 등록 페이로드 (아래 매트릭스). SMS 단독 알림은 content 없이 생성 가능 — 검수 신청 시점에 존재를 재검한다 |
+
+**content 검증 매트릭스** (`content` 를 보냈을 때만 적용. 서버가 선택 유형과 무관한 필드·빈 값을 검증 전에 제거(prune)하므로 화면은 폼 상태 전체를 그대로 보내도 된다)
+
+| 필드 | 타입 | 필수 | 제약 |
+| --- | --- | --- | --- |
+| content.templateName | string | content 있으면 필수 | max 200 |
+| content.templateMessageType | string | content 있으면 필수 | `BA`(기본형)/`EX`(부가정보형)/`AD`(채널추가형)/`MI`(복합형) |
+| content.templateEmphasizeType | string | content 있으면 필수 | `NONE`/`TEXT`(강조표기)/`IMAGE`(이미지형)/`ITEM_LIST`(아이템리스트) |
+| content.templateContent | string | content 있으면 필수 | max 1000 |
+| content.templatePreviewMessage | string | 아니오 | max 40 |
+| content.categoryCode | string | content 있으면 필수 | max 20 (`GET /admin/alimtalk-templates/categories` 의 코드) |
+| content.securityFlag | boolean | 아니오 | — |
+| content.templateExtra | string | messageType `EX`/`MI` 이면 필수 | 부가정보 |
+| content.templateTitle / templateSubtitle | string | emphasizeType `TEXT` 이면 필수쌍 | 길이 수치 미기재 항목은 kapi 가 최종 게이트 |
+| content.templateHeader | string | 아니오 | max 16 |
+| content.templateImageName / templateImageUrl | string | emphasizeType `IMAGE` 이면 필수쌍 | url max 500 — 업로드 프록시(`POST /admin/templates/image`) 응답값만 기입 |
+| content.templateItem | object | emphasizeType `ITEM_LIST` 이면 필수 | `list` 2~10개 필수 · 각 `title` max 6 · `description` max 23 / `summary.title` max 6 · `summary.description` max 14 |
+| content.templateItemHighlight | object | 아니오 | 썸네일(imageUrl) 없으면 title ≤30·description ≤19, 있으면 ≤21·≤13 / imageUrl max 500 |
+| content.templateRepresentLink | object | 아니오 | linkMo/linkPc/linkAnd/linkIos 각 max 500 |
+| content.buttons | array | 아니오 | max 5 · 각 `name` 필수 max 14 · `linkType` in `WL,AL,DS,BK,MD,AC,BC,BT,P1,P2,P3,TN,MP` |
+| content.quickReplies | array | 아니오 | max 10 · 각 `name` 필수 · `linkType` in `WL,AL,BK,MD,BC,BT` |
+
+버튼·바로연결 항목별 조건부 필수: `WL` → `linkMo` / `AL` → `linkAnd`+`linkIos` 둘 다 / `TN` → `telNumber` / `P1`~`P3` → `pluginId`.
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/templates HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "notification_type": "order_completed",
+ "alimtalk_enabled": true,
+ "fallback_sms_enabled": true,
+ "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."},
+ "content": {
+ "templateName": "주문 완료 안내",
+ "templateMessageType": "BA",
+ "templateEmphasizeType": "NONE",
+ "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.",
+ "categoryCode": "004001",
+ "buttons": [
+ { "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" }
+ ]
+ }
+}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```http
+HTTP/1.1 201
+```
+
+```json
+{
+ "success": true,
+ "message": "알림 템플릿을 저장했습니다.",
+ "data": {
+ "template": {
+ "id": 4,
+ "notification_type": "order_completed",
+ "alimtalk_enabled": true,
+ "template_code": null,
+ "sender_key": null,
+ "content": { "templateName": "주문 완료 안내", "templateMessageType": "BA", "templateEmphasizeType": "NONE", "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 완료되었습니다.", "categoryCode": "004001", "buttons": [{ "name": "주문 확인", "linkType": "WL", "linkMo": "https://example.com/orders" }] },
+ "approved_content": null,
+ "status": "draft",
+ "is_approved": false,
+ "inspection_detail": null,
+ "requested_at": null,
+ "approved_at": null,
+ "last_synced_at": null,
+ "fallback_sms_enabled": true,
+ "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."},
+ "sms_only": false,
+ "is_active": true
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 422 | Unprocessable Entity | 검증 실패 (알림 정의 부재·이미 등록된 notification_type·content 매트릭스 위반 등) |
+
+**설명**
+
+템플릿 행을 `status=draft` 로 생성한다. 이 시점에는 kapi 를 호출하지 않는다 — 카카오 등록은
+검수 신청(`POST /admin/templates/{id}/request`) 시점에 수행된다.
+
+
+### PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.update`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@update`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+| alimtalk_enabled | body | boolean | 아니오 | — | 알림톡 사용 여부 |
+| fallback_sms_enabled | body | boolean | 아니오 | — | SMS 대체발송 여부 |
+| sms_body | body | object | 아니오 | nullable · 로케일별 max 2000 | 대체/단독 SMS 본문의 로케일 맵. 키는 시스템 지원 로케일(`app.translatable_locales`), 값은 각 언어 본문 |
+| sms_only | body | boolean | 아니오 | — | SMS 단독 발송 여부 |
+| is_active | body | boolean | 아니오 | — | 발송 활성 여부 |
+| content | body | object | 아니오 | nullable · Store 와 동일 매트릭스 | 카카오 등록 페이로드 |
+
+`notification_type` 은 행 정체성이라 수정 대상이 아니다 — 전달돼도 무시된다.
+
+**요청 예시**
+
+```http
+PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/4 HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "content": {
+ "templateName": "주문 완료 안내",
+ "templateMessageType": "BA",
+ "templateEmphasizeType": "NONE",
+ "templateContent": "#{user_name}님, 주문 #{order_id} 이(가) 결제 완료되었습니다.",
+ "categoryCode": "004001"
+ }
+}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "알림 템플릿을 수정했습니다.",
+ "data": {
+ "template": { "id": 4, "status": "draft", "...": "상세 객체 필드 동일" }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+| 422 | Unprocessable Entity | 검증 실패, 또는 content 변경 불가 상태 ("현재 상태(:status)에서는 알림톡 내용을 수정할 수 없습니다. …") |
+
+**설명**
+
+`content` 필드를 **포함한 요청**은 `draft`/`rejected` 상태에서만 허용된다 —
+`requested` 는 검수 취소를, `approved` 는 승인 취소를 먼저 해야 하며, 위반 시 422 를 반환한다.
+판정은 `content` 키의 포함 여부만 보므로, 기존 값과 동일한 `content` 를 다시 보내는 것도 거부된다.
+발송 설정 필드(alimtalk_enabled/fallback_sms_enabled/sms_body/sms_only/is_active)는 라이프사이클과
+무관하므로 상태와 관계없이 수정할 수 있다 — 단 그 경우 요청 본문에 `content` 를 싣지 않아야 한다.
+
+
+### PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{notificationType}
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.delivery`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@upsertDelivery`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| notificationType | path | string | 예 | max 100 · 알림 정의 존재(exists) | 코어 알림 정의 type |
+| alimtalk_enabled | body | boolean | 아니오 | — | 알림톡 사용 여부 |
+| fallback_sms_enabled | body | boolean | 아니오 | — | SMS 대체발송 여부 |
+| sms_body | body | object | 아니오 | nullable · 로케일별 max 2000 | 대체/단독 SMS 본문의 로케일 맵. 키는 시스템 지원 로케일(`app.translatable_locales`), 값은 각 언어 본문 |
+| sms_only | body | boolean | 아니오 | — | SMS 단독 발송 여부 |
+| is_active | body | boolean | 아니오 | — | 발송 활성 여부 |
+
+**요청 예시**
+
+```http
+PUT /api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/order_completed HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: application/json
+
+{
+ "fallback_sms_enabled": true,
+ "sms_body": {"ko": "#{user_name}님, 주문이 완료되었습니다.", "en": "#{user_name}, your order is complete."}
+}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "알림 템플릿을 수정했습니다.",
+ "data": {
+ "template": { "id": 4, "notification_type": "order_completed", "status": "draft", "...": "상세 객체 필드 동일" }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 422 | Unprocessable Entity | `notificationType` 에 해당하는 알림 정의가 없는 경우 등 검증 실패 |
+
+**설명**
+
+알림 설정 탭 행 하단 토글(알림톡 사용·대체 SMS·SMS 본문·SMS 단독·활성)의 즉시 저장 경로다.
+대상 행이 없으면 `draft` 로 생성한다(upsert). 알림톡 content 는 이 경로로 변경할 수 없다(작성 모달 전용)
+— content 를 건드리지 않으므로 상태 가드도 없다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/request
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.request`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@requestInspection`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+| comment | body | string | 아니오 | 500자 이내 (앞뒤 공백 제거, 비어 있으면 미전달) | 검수자 전달 의견 — kapi `template/request` 의 comment 로 그대로 실린다. 행에는 저장하지 않는다 |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/request HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer {YOUR_TOKEN}
+
+{
+ "comment": "변수 예시: #{name}=홍길동, #{order_number}=20260823-0001"
+}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "검수를 신청했습니다. 승인 결과는 자동 동기화되며 [새로고침]으로 즉시 확인할 수 있습니다.",
+ "data": {
+ "template": {
+ "id": 4,
+ "template_code": "g7_1a2b3c4d_1",
+ "status": "requested",
+ "requested_at": "2026-08-19T10:00:00+09:00",
+ "last_synced_at": "2026-08-19T10:00:00+09:00",
+ "...": "상세 객체 필드 동일"
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+| 422 | Unprocessable Entity | comment 500자 초과 / content 미작성 / 신청 불가 상태(draft·rejected 외) / kapi 실패(`errors.bizppurio_message`·`errors.result_code`) / 발신프로필 키(sender_key) 미설정 / 템플릿 코드 채번 실패 |
+
+**설명**
+
+검수 신청의 전체 시퀀스: ① `template_code` 가 없으면(최초) 자체 채번 — `g7_{md5(notification_type) 앞 8자}_{세대}`
+형식을 우리 DB 와 kapi codeCheck 양쪽에서 검증하고 충돌 시 세대를 올려 최대 3회 재시도 →
+② kapi add(최초) 또는 update(재신청) 로 카카오에 등록 → ③ kapi request 로 검수 신청 → ④ `status=requested`.
+add 성공 시점에만 template_code 를 행에 확정하므로, add 자체가 실패하면 다음 시도에서 다시 채번부터 진행된다.
+`draft`/`rejected` 상태에서만 신청할 수 있으며(requested 는 이미 검수중, approved 는 승인 취소 먼저),
+content 미작성이면 422 를 반환한다. 성공 시 `inspection_detail` 은 초기화된다.
+요청 본문의 `comment`(선택, ≤500)는 ③ 의 kapi request 에 검수자 전달 의견으로 그대로 실리며 행에는 저장하지 않는다 —
+카카오 심사 가이드가 본문 변수마다 요구하는 '예시 텍스트' 를 검수자에게 전할 유일한 통로다(kapi `template/add` 에는
+예시 필드가 없다). 500자 초과는 kapi 호출 전에 422 로 끝나므로 행 상태와 선점은 바뀌지 않는다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/cancel-request
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.cancel-request`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@cancelRequest`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/cancel-request HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "검수 신청을 취소했습니다.",
+ "data": {
+ "template": { "id": 4, "status": "draft", "...": "상세 객체 필드 동일" }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+| 422 | Unprocessable Entity | `requested` 상태가 아닌 경우 / kapi 실패 |
+
+**설명**
+
+kapi 검수 취소(카카오측 REQ→REG)를 호출한 뒤 우리 상태를 `draft` 로 되돌린다.
+`requested` 상태에서만 호출할 수 있다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/cancel-approval
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.cancel-approval`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@cancelApproval`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/templates/3/cancel-approval HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "승인을 취소했습니다. 이 알림의 알림톡 발송이 중단되었습니다.",
+ "data": {
+ "template": { "id": 3, "status": "draft", "approved_content": { "...": "스냅샷 유지" }, "...": "상세 객체 필드 동일" }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+| 422 | Unprocessable Entity | `approved` 상태가 아닌 경우 / kapi 실패 |
+
+**설명**
+
+kapi 승인 취소(카카오측 승인→등록 복귀)를 호출한 뒤 우리 상태를 `draft` 로 되돌린다.
+`approved_content` 스냅샷은 유지되지만 status 가 `approved` 를 벗어나므로 **알림톡 발송이 즉시 차단**된다
+— 운영자 확인 모달이 이 효과를 사전 경고한다. `approved` 상태에서만 호출할 수 있다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/release
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.release`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@release`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/templates/3/release HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "휴면 상태를 해제했습니다.",
+ "data": {
+ "template": { "id": 3, "status": "approved", "last_synced_at": "2026-08-19T10:05:00+09:00", "...": "상세 객체 필드 동일" }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+| 422 | Unprocessable Entity | `dormant` 상태가 아닌 경우 / kapi 실패 |
+
+**설명**
+
+kapi 휴면 해제를 호출한 뒤, 카카오측 실제 상태를 되받아 재동기화(sync)해 반영한다 —
+응답의 status 는 해제 후 카카오가 알려준 실제 상태다. `dormant` 상태에서만 호출할 수 있다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}/sync
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.sync`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@sync`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/templates/4/sync HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data.template` — 상세 객체, `GET /admin/templates/{id}` 와 동일)
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "카카오 검수 상태를 동기화했습니다.",
+ "data": {
+ "template": {
+ "id": 4,
+ "status": "approved",
+ "approved_content": { "...": "승인 전이 시 content 동결 스냅샷" },
+ "approved_at": "2026-08-19T10:10:00+09:00",
+ "last_synced_at": "2026-08-19T10:10:00+09:00",
+ "...": "상세 객체 필드 동일"
+ }
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+| 422 | Unprocessable Entity | kapi 실패 |
+
+**설명**
+
+관리 화면 [새로고침] 버튼의 수동 상태 동기화다. kapi 상세를 조회해 카카오측 serviceStatus 를 우리
+상태로 환원해 반영한다. **승인으로 전이하는 시점에 `content` 를 `approved_content` 로 동결**(발송 SSoT)하고
+`approved_at` 을 확정하며, **반려 시 상세의 comments 를 `inspection_detail` 로 저장**한다.
+알 수 없는 serviceStatus 는 상태를 덮어쓰지 않고 `last_synced_at` 만 갱신한다.
+`template_code` 가 없는(카카오 등록 전) 행은 동기화 대상이 아니므로 행을 그대로 반환한다.
+검수중(requested) 행 전체의 자동 동기화는 스케줄 커맨드(`bizppurio:sync-template-status`)가 담당한다.
+
+
+### POST /api/plugins/sirsoft-message_bizppurio/admin/templates/image
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.image`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@uploadImage`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| image | body (multipart) | file | 예 | jpg/jpeg/png · ≤500KB · 가로 ≥500px · 가로:세로 = 2:1 | 이미지형(IMAGE) 템플릿용 이미지 파일 |
+
+**요청 예시**
+
+```http
+POST /api/plugins/sirsoft-message_bizppurio/admin/templates/image HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+Content-Type: multipart/form-data; boundary=----boundary
+
+------boundary
+Content-Disposition: form-data; name="image"; filename="banner.png"
+Content-Type: image/png
+
+(binary)
+------boundary--
+```
+
+**응답 필드** (`data` 내부)
+
+| 필드 | 타입 | 용도/설명 |
+| --- | --- | --- |
+| url | string | 카카오가 반환한 이미지 URL — `content.templateImageUrl` 에 그대로 기입한다 |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "이미지를 업로드했습니다.",
+ "data": {
+ "url": "https://mud-kage.kakao.com/dn/example/img_1234567890.png"
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 422 | Unprocessable Entity | 파일 형식/크기/치수/비율 위반 (프록시 단계 사전 검증) / kapi 업로드 실패 |
+
+**설명**
+
+이미지형(`templateEmphasizeType=IMAGE`) 템플릿용 이미지의 kapi 업로드 프록시다. kapi 제약
+(jpg/png · ≤500KB · 가로 ≥500px · 가로:세로 2:1)을 프록시 단계에서 사전 검증해 kapi 왕복 없이
+즉시 인라인 오류를 돌려준다. 성공 시 `data.url` 이 카카오 이미지 URL 이며, 화면은 이 값만
+`content.templateImageUrl` 에 기입한다(임의 URL 기입 금지).
+
+
+### DELETE /api/plugins/sirsoft-message_bizppurio/admin/templates/{id}
+
+- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.templates.destroy`
+- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\BizppurioTemplateController@destroy`
+- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.manage`
+
+**요청 파라미터**
+
+| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |
+| --- | --- | --- | --- | --- | --- |
+| id | path | integer | 예 | 숫자만 | 템플릿 PK |
+
+**요청 예시**
+
+```http
+DELETE /api/plugins/sirsoft-message_bizppurio/admin/templates/4 HTTP/1.1
+Host: api.example.com
+Accept: application/json
+Authorization: Bearer {YOUR_TOKEN}
+```
+
+**응답 필드** (`data` 내부)
+
+| 필드 | 타입 | 용도/설명 |
+| --- | --- | --- |
+| kakao_deleted | boolean | 카카오측 템플릿도 함께 삭제했는지 여부 |
+| kakao_skip_reason | string\|null | 카카오측 미삭제 사유: `not_registered`(카카오 등록 전) / `state_not_deletable`(삭제 가능 상태 아님) / kapi 삭제 실패 시 카카오 사유 원문. 삭제했으면 null |
+
+**응답 예시**
+
+```json
+{
+ "success": true,
+ "message": "알림 템플릿을 삭제했습니다.",
+ "data": {
+ "kakao_deleted": false,
+ "kakao_skip_reason": "state_not_deletable"
+ }
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
+| 403 | Forbidden | 요구 권한(`sirsoft-message_bizppurio.messaging.manage`)이 없는 경우 |
+| 404 | Not Found | `{id}` 에 해당하는 템플릿이 없는 경우 |
+
+**설명**
+
+DB 행은 항상 삭제한다. 카카오측 템플릿은 삭제 가능 상태일 때만 동반 삭제한다 — kapi delete 는
+등록(REG)/반려(REJ) 상태에서만 허용되므로, 우리 상태가 `draft`/`rejected` 면 kapi 삭제를 시도하고,
+그 외 상태(또는 kapi 삭제 실패)면 카카오측은 그대로 두고 DB 행만 삭제한다. 어느 쪽이든 응답의
+`kakao_deleted`·`kakao_skip_reason` 으로 결과를 명시해 운영자가 알 수 있게 한다(kapi 실패가 있어도
+이 엔드포인트는 422 가 아닌 200 으로 완료된다).
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..2e390b3d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/docs/api/webhook.md
@@ -0,0 +1,96 @@
+# 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 | 발송 메시지 유형(SMS/LMS/MMS/AT 등 비즈뿌리오 코드) |
+| CMSGID | body | string | 아니오 | max 64 | 비즈뿌리오 메시지 키(발송 요청 시 채번된 식별자) |
+| MSGID | body | string | 아니오 | max 64 | 비즈뿌리오 내부 메시지 ID |
+| PHONE | body | string | 아니오 | max 20 | 수신 전화번호 |
+| MEDIA | body | string | 아니오 | max 10 | 실제 발송된 매체 유형(대체발송 시 요청 유형과 다를 수 있음) |
+| RESULT | body | string | 예 | max 10 | 발송 결과 코드 — ResultCodeResolver 가 성공/실패와 사유로 해석 |
+| REFKEY | body | string | 예 | max 32 | 발송 시 G7 이 부여한 참조 키 — 이 값으로 bizppurio_dispatches 행을 찾는다 |
+| 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` 내부)
+
+이 엔드포인트는 `data` 를 반환하지 않는다. 비즈뿌리오 URL PUSH 규약상 수신 사실만 알리면 되므로,
+성공·실패와 무관하게 `success: true` + 수신 확인 메시지만 담은 200 봉투를 돌려준다.
+리포트 해석 결과(성공/실패·사유)는 `bizppurio_dispatches` 행에 기록되며 응답에는 싣지 않는다.
+
+**응답 예시**
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+ "success": true,
+ "message": "발송 결과 리포트를 수신했습니다."
+}
+```
+
+**에러 응답**
+
+| 상태코드 | 의미 | 발생 조건 |
+| --- | --- | --- |
+| 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..d798ced0
--- /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..abafde00
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/en/messages.php
@@ -0,0 +1,187 @@
+ [
+ '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' => 'Bizppurio SMS',
+ 'description' => 'Send notifications as SMS/LMS text messages via Bizppurio.',
+ ],
+ 'alimtalk' => [
+ 'name' => 'Bizppurio 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.',
+ 'image_unreadable' => 'The uploaded image file could not be read.',
+ ],
+
+ // Send skipped (channel driver send() precondition not met — recorded as "Failed" in core notification log)
+ 'send_skipped' => [
+ 'alimtalk_template_not_approved' => 'Skipped sending: no approved alimtalk template or alimtalk delivery is off. (notification type: :type)',
+ 'sms_template_missing' => 'Skipped sending: no SMS body is configured. (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 template lifecycle (#597 — BizppurioTemplateController responses and state guards)
+ 'template' => [
+ 'created' => 'Notification template saved.',
+ 'updated' => 'Notification template updated.',
+ 'requested' => 'Inspection requested. The approval result syncs automatically, or use [Refresh] to check now.',
+ 'request_cancelled' => 'Inspection request cancelled.',
+ 'approval_cancelled' => 'Approval cancelled. Alimtalk delivery for this notification has stopped.',
+ 'released' => 'Dormant state released.',
+ 'synced' => 'Kakao inspection status synchronized.',
+ 'deleted' => 'Notification template deleted.',
+ 'image_uploaded' => 'Image uploaded.',
+ 'content_locked' => 'The alimtalk content cannot be edited in the current state (:status). Cancel the inspection request first, or cancel the approval if approved.',
+ 'content_missing' => 'Please compose the alimtalk template content first.',
+ 'request_not_allowed' => 'Inspection cannot be requested in the current state (:status).',
+ 'cancel_request_not_allowed' => 'The request cannot be cancelled because the template is not under inspection. (current: :status)',
+ 'cancel_approval_not_allowed' => 'Approval cannot be cancelled because the template is not approved. (current: :status)',
+ 'release_not_allowed' => 'The template is not dormant. (current: :status)',
+ 'code_generation_failed' => 'Failed to allocate a template code. Please try again later.',
+ ],
+
+ // Notification template validation (FormRequest — only documented limits; kapi is the final gate)
+ 'validation' => [
+ 'link_mo_required' => 'A web link (WL) button requires a mobile link.',
+ 'link_and_required' => 'An app link (AL) button requires an Android scheme.',
+ 'link_ios_required' => 'An app link (AL) button requires an iOS scheme.',
+ 'tel_number_required' => 'A phone (TN) button requires a phone number.',
+ 'plugin_id_required' => 'A plugin (P1–P3) button requires a plugin ID.',
+ 'highlight_title_too_long' => 'The item highlight title must be at most :max characters.',
+ 'highlight_description_too_long' => 'The item highlight description must be at most :max characters.',
+ 'image_ratio_invalid' => 'The image must have a 2:1 width-to-height ratio. (e.g. 1000×500px)',
+
+ // Field labels used in validation messages (FormRequest::attributes)
+ 'attributes' => [
+ 'notification_type' => 'notification type',
+ 'content' => 'AlimTalk content',
+ 'template_name' => 'template name',
+ 'message_type' => 'message type',
+ 'emphasize_type' => 'emphasis type',
+ 'template_content' => 'body',
+ 'comment' => 'reviewer comment',
+ 'preview_message' => 'preview message',
+ 'category_code' => 'category',
+ 'security_flag' => 'secure template flag',
+ 'extra' => 'supplementary information',
+ 'title' => 'emphasis title',
+ 'subtitle' => 'emphasis subtitle',
+ 'header' => 'header',
+ 'image_name' => 'image file name',
+ 'image_url' => 'image URL',
+ 'item' => 'item list',
+ 'item_list' => 'items',
+ 'item_title' => 'item title',
+ 'item_description' => 'item description',
+ 'summary' => 'summary',
+ 'summary_title' => 'summary title',
+ 'summary_description' => 'summary description',
+ 'highlight' => 'highlight',
+ 'highlight_title' => 'highlight title',
+ 'highlight_description' => 'highlight description',
+ 'highlight_image_url' => 'highlight image URL',
+ 'represent_link' => 'representative link',
+ 'buttons' => 'buttons',
+ 'button_name' => 'button label',
+ 'button_link_type' => 'button link type',
+ 'quick_replies' => 'quick replies',
+ 'quick_reply_name' => 'quick reply label',
+ 'quick_reply_link_type' => 'quick reply link type',
+ 'link_mo' => 'mobile link',
+ 'link_pc' => 'PC link',
+ 'link_and' => 'Android scheme',
+ 'link_ios' => 'iOS scheme',
+ 'tel_number' => 'phone number',
+ 'plugin_id' => 'plugin ID',
+ 'image' => 'image file',
+ 'sms_body' => 'SMS body',
+ 'alimtalk_enabled' => 'AlimTalk enabled',
+ 'fallback_sms_enabled' => 'fallback SMS enabled',
+ 'sms_only' => 'SMS only',
+ 'is_active' => 'active',
+ 'status' => 'status',
+ 'search' => 'search keyword',
+ 'page' => 'page',
+ 'per_page' => 'items per page',
+ ],
+ ],
+
+ // 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..14c72a84
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/activity_log.php
@@ -0,0 +1,10 @@
+ [],
+ '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..03bfeb60
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/lang/ko/messages.php
@@ -0,0 +1,187 @@
+ [
+ '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' => '비즈뿌리오 문자',
+ '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' => '알림톡 발신프로필 키를 먼저 설정하세요.',
+ 'image_unreadable' => '업로드한 이미지 파일을 읽을 수 없습니다.',
+ ],
+
+ // 발송 건너뜀 (채널 드라이버 send() 사전 조건 미충족 — 코어 발송 이력에 "실패"로 기록됨)
+ 'send_skipped' => [
+ 'alimtalk_template_not_approved' => '승인된 알림톡 템플릿이 없거나 알림톡 발송이 꺼져 있어 발송을 건너뛰었습니다. (알림 유형: :type)',
+ 'sms_template_missing' => 'SMS 본문이 설정되지 않아 발송을 건너뛰었습니다. (알림 유형: :type)',
+ 'recipient_phone_missing' => '수신자 전화번호가 없어 발송을 건너뛰었습니다. (알림 유형: :type)',
+ 'message_body_empty' => '발송 본문이 비어 있어 발송을 건너뛰었습니다. (알림 유형: :type)',
+ ],
+
+ // 알림 템플릿 라이프사이클 (#597 — BizppurioTemplateController 응답·상태 가드)
+ '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' => '템플릿 코드를 채번하지 못했습니다. 잠시 후 다시 시도하세요.',
+ ],
+
+ // 알림 템플릿 검증 (FormRequest — 문서 수치 명시 제약만, 그 외는 kapi 최종 게이트)
+ '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' => '본문',
+ 'comment' => '검수자 전달 의견',
+ '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' => '페이지당 건수',
+ ],
+ ],
+
+ // 연결 확인 (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..3a8d1cf9
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/package.json
@@ -0,0 +1,22 @@
+{
+ "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",
+ "test:e2e": "playwright test --config=tests/Playwright/playwright.config.ts",
+ "test:e2e:ui": "playwright test --config=tests/Playwright/playwright.config.ts --ui"
+ },
+ "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..3722d29f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/plugin.php
@@ -0,0 +1,495 @@
+ '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() 가 재시딩하며
+ * 자동으로 복원된다.
+ *
+ * 반대로 우리 소유 테이블(bizppurio_templates·bizppurio_dispatches)은 여기서 손대지 않는다.
+ * 코어 PluginManager 가 "데이터도 함께 삭제" 를 선택했을 때만 rollbackMigrations() 로,
+ * 그것도 uninstall() 호출 **이전에** 제거하기 때문이다. 여기에 dropIfExists 를 더하면
+ * 테이블 정리 책임이 두 곳으로 갈라지고, 데이터 보존을 선택한 운영자의 테이블까지 지운다.
+ *
+ * @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,
+ ],
+ ];
+ }
+
+ /**
+ * 플러그인 설정 기본값 반환 (하위 호환)
+ *
+ * 신규 설치 기본값은 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' => '',
+ ];
+ }
+
+ /**
+ * 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다.
+ *
+ * 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,
+ ];
+ }
+
+ /**
+ * 스케줄 작업 목록 반환 (#597 §3.4).
+ *
+ * 검수중(requested) 알림톡 템플릿의 카카오 검수 상태를 30분 주기로 동기화한다.
+ * 커맨드는 requested 행이 없으면 카카오 API 를 호출하지 않으며, 화면의 수동
+ * [새로고침]이 동등 기능을 제공하므로 cron 미가동 환경에서도 승인 확인이 가능하다.
+ *
+ * @return array> 스케줄 정의 목록
+ */
+ public function getSchedules(): array
+ {
+ return [
+ [
+ 'command' => 'bizppurio:sync-template-status',
+ 'schedule' => 'everyThirtyMinutes',
+ 'description' => '비즈뿌리오 알림톡 템플릿 검수 상태 동기화',
+ ],
+ ];
+ }
+
+ /**
+ * 확장 미들웨어 선언 (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..28055f82
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_row_footer.json
@@ -0,0 +1,195 @@
+{
+ "extension_point": "notification_definition_row_footer",
+ "comment": "알림 설정 '비즈뿌리오' 통합 탭의 각 알림 행 하단 상태 요약(제품 결정 2026-08-23 — 버튼 없음, 조작은 코어 [편집] 모달로 일원화). activeChannel==='alimtalk'(통합 탭 정체성)일 때만 노출. 요약 데이터는 bizppurioTemplates(각 탭 오버레이가 등록·조회, GET templates/map)에서 def.type 으로 읽는다. 1줄: 알림톡 승인 2단 배지 + 신청/동기화일 + 발송 사용 여부 / 2줄: 문자(SMS) 대체·단독 사용 여부 + 본문 미리보기.",
+ "priority": 320,
+ "components": [
+ {
+ "id": "bizppurio_row_lifecycle",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{extensionPointProps.activeChannel === 'alimtalk'}}",
+ "props": {
+ "className": "mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center flex-wrap gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-comment-dots",
+ "className": "text-sm text-teal-600 dark:text-teal-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_label"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "comment": "승인 여부 2단 배지(제품 결정 2026-08-23): 1차 = 승인됨/미승인, 2차(괄호) = 세부 상태(미작성·작성중·검수중·반려·휴면·중지·차단). approved 는 세부 없이 단독 표기.",
+ "props": {
+ "className": "{{'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'stopped' || (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{$t('sirsoft-message_bizppurio.template.approval.' + ((((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') === 'approved' ? 'approved' : 'not_approved'))}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{(((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten') !== 'approved'}}",
+ "props": {
+ "className": "font-normal opacity-80"
+ },
+ "text": "{{'(' + $t('sirsoft-message_bizppurio.template.status.' + (((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)) ? bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status : 'unwritten')) + ')'}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content === true) && (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled === false)}}",
+ "props": {
+ "className": "text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.alimtalk_disabled"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center flex-wrap gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-comment-sms",
+ "className": "text-sm text-gray-500 dark:text-gray-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-sm font-medium text-gray-700 dark:text-gray-200 flex-shrink-0"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.sms_label"
+ },
+ {
+ "id": "bizppurio_row_sms_fallback",
+ "type": "basic",
+ "name": "Div",
+ "comment": "fallback_sms 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(제품 결정 2026-08-23, 행의 활성·기본·승인 배지와 일관).",
+ "props": {
+ "className": "flex-center gap-1 flex-shrink-0"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms"
+ },
+ {
+ "id": "bizppurio_row_sms_fallback_badge",
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
+ },
+ "text": "{{$t('sirsoft-message_bizppurio.template.row.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled) ? 'on' : 'off'))}}"
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_row_sms_only",
+ "type": "basic",
+ "name": "Div",
+ "comment": "sms_only 상태 배지 — 사용(초록)/미사용(회색). 문구 대신 배지(제품 결정 2026-08-23, 행의 활성·기본·승인 배지와 일관).",
+ "props": {
+ "className": "flex-center gap-1 flex-shrink-0"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.sms_only"
+ },
+ {
+ "id": "bizppurio_row_sms_only_badge",
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
+ },
+ "text": "{{$t('sirsoft-message_bizppurio.template.row.' + ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only) ? 'on' : 'off'))}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body === true}}",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400 truncate max-w-md"
+ },
+ "text": "{{$t('sirsoft-message_bizppurio.template.row.sms_body_prefix') + ' ' + (bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body_preview ?? '')}}"
+ },
+ {
+ "id": "bizppurio_row_sms_body_missing",
+ "type": "basic",
+ "name": "Span",
+ "comment": "본문 미설정 = 값 없음 — '발송 사용 안 함' 칩과 같은 외곽선 스타일.",
+ "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_sms_body !== true}}",
+ "props": {
+ "className": "text-xs px-1.5 py-0.5 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 flex-shrink-0"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.sms_body_missing"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
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..e0720fd0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_board.json
@@ -0,0 +1,202 @@
+{
+ "target_layout": "admin_board_settings",
+ "comment": "[게시판 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 게시판 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, 제품 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.",
+ "data_sources": [
+ {
+ "id": "bizppurioTemplates",
+ "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "fallback": {
+ "data": {
+ "templates": {}
+ }
+ }
+ },
+ {
+ "id": "bizppurioCategories",
+ "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "categories": [],
+ "load_failed": true
+ }
+ }
+ },
+ {
+ "id": "bizppurioProfiles",
+ "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "profiles": [],
+ "load_failed": true
+ }
+ }
+ }
+ ],
+ "injections": [
+ {
+ "target_id": "board_notif_channel_content",
+ "position": "prepend_child",
+ "comment": "비즈뿌리오 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드. 탭 정체성은 query.channel==='alimtalk'.",
+ "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",
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.banner.not_ready",
+ "props": {
+ "className": "text-sm text-red-800 dark:text-red-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2"
+ }
+ },
+ {
+ "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"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/sirsoft-message_bizppurio/settings"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.banner.setup_action"
+ }
+ ]
+ },
+ {
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.banner.test_mode",
+ "props": {
+ "className": "text-sm text-amber-800 dark:text-amber-200"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.template.tab_guide",
+ "props": {
+ "className": "text-sm text-blue-800 dark:text-blue-200"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "init_actions": [
+ {
+ "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ }
+ ],
+ "priority": 320
+}
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..b8096b0d
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_core.json
@@ -0,0 +1,202 @@
+{
+ "target_layout": "admin_settings",
+ "comment": "알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, 제품 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.",
+ "data_sources": [
+ {
+ "id": "bizppurioTemplates",
+ "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "fallback": {
+ "data": {
+ "templates": {}
+ }
+ }
+ },
+ {
+ "id": "bizppurioCategories",
+ "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "categories": [],
+ "load_failed": true
+ }
+ }
+ },
+ {
+ "id": "bizppurioProfiles",
+ "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "profiles": [],
+ "load_failed": true
+ }
+ }
+ }
+ ],
+ "injections": [
+ {
+ "target_id": "notif_channel_content",
+ "position": "prepend_child",
+ "comment": "비즈뿌리오 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드. 탭 정체성은 query.channel==='alimtalk'.",
+ "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",
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.banner.not_ready",
+ "props": {
+ "className": "text-sm text-red-800 dark:text-red-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2"
+ }
+ },
+ {
+ "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"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/sirsoft-message_bizppurio/settings"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.banner.setup_action"
+ }
+ ]
+ },
+ {
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.banner.test_mode",
+ "props": {
+ "className": "text-sm text-amber-800 dark:text-amber-200"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.template.tab_guide",
+ "props": {
+ "className": "text-sm text-blue-800 dark:text-blue-200"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "init_actions": [
+ {
+ "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ }
+ ],
+ "priority": 320
+}
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..c2c52518
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_tab_ecommerce.json
@@ -0,0 +1,202 @@
+{
+ "target_layout": "admin_ecommerce_settings",
+ "comment": "[이커머스 면 — notification_tab_core.json 과 동일 패턴, target/컴포넌트 id 만 이커머스 전용] 알림 설정 '비즈뿌리오' 통합 탭 오버레이(#597 §3.3, 제품 결정 2026-08-23 개정). ① 탭 상단 상태 배너(문제 있을 때만: readiness/테스트 모드) + 안내 박스 ② 요약 데이터소스 bizppurioTemplates(GET templates/map)는 탭 진입 시 init_actions 로 조회 — 행 하단 상태 요약(notification_row_footer.json)과 [편집] 모달 섹션(notification_template_form_sections.json)이 공유한다 ③ 작성 셀렉트용 카테고리·발신프로필 데이터소스. 알림톡 작성/검수/SMS 본문 편집 UI 는 코어 [편집] 모달에 주입되므로 이 오버레이는 더 이상 모달을 등록하지 않는다.",
+ "data_sources": [
+ {
+ "id": "bizppurioTemplates",
+ "comment": "행 라이프사이클 표시용 요약 맵(notification_type 키). auto_fetch:false — 설정 페이지 전 탭 자동 호출 방지, 비즈뿌리오 탭 진입 시 init_actions 로 조회.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates/map",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "fallback": {
+ "data": {
+ "templates": {}
+ }
+ }
+ },
+ {
+ "id": "bizppurioCategories",
+ "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 자격증명 미설정 422 는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "categories": [],
+ "load_failed": true
+ }
+ }
+ },
+ {
+ "id": "bizppurioProfiles",
+ "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "profiles": [],
+ "load_failed": true
+ }
+ }
+ }
+ ],
+ "injections": [
+ {
+ "target_id": "ecommerce_notif_channel_content",
+ "position": "prepend_child",
+ "comment": "비즈뿌리오 탭 상단 상태 배너. 문제 있을 때만 노출(정상=배너 없음). readiness 미충족 🔴 설정하기 / is_test_mode 🟡 테스트 모드. 탭 정체성은 query.channel==='alimtalk'.",
+ "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",
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.banner.not_ready",
+ "props": {
+ "className": "text-sm text-red-800 dark:text-red-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2"
+ }
+ },
+ {
+ "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"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/sirsoft-message_bizppurio/settings"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.banner.setup_action"
+ }
+ ]
+ },
+ {
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.banner.test_mode",
+ "props": {
+ "className": "text-sm text-amber-800 dark:text-amber-200"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.template.tab_guide",
+ "props": {
+ "className": "text-sm text-blue-800 dark:text-blue-200"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "init_actions": [
+ {
+ "comment": "비즈뿌리오 탭 진입 시 요약 맵 조회(행 라이프사이클 표시용). 다른 탭에선 자동 호출 안 됨(auto_fetch:false).",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ }
+ ],
+ "priority": 320
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json
new file mode 100644
index 00000000..ceae5d10
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_footer_actions.json
@@ -0,0 +1,797 @@
+{
+ "extension_point": "notification_template_form_footer_actions",
+ "comment": "코어 알림 템플릿 [편집] 모달 푸터에 주입되는 통합 저장 버튼(제품 결정 2026-08-23 — 저장은 하나로). 채널 메타 hidden_template_editor 로 코어 [저장]/[미리보기]가 숨겨진 자리에서, [저장] 한 번으로 ① 알림톡 템플릿(draft/rejected 만 내용 갱신) + 발송 설정·SMS 본문 ② 수신자 규칙이 바뀐 경우에만 코어 채널 템플릿을 순차 저장한다(앞 단계 실패 시 중단). [저장 후 검수 신청]은 알림톡 본문이 있고 수정 가능한 상태에서만 노출된다 — 카카오 검수는 알림톡 템플릿만 대상이며 SMS 는 검수 없이 즉시 반영된다. 더블클릭 재진입은 click if + disabled 로 막고, 중복 검수 신청의 SSoT 는 서버 원자 선점이다.",
+ "priority": 320,
+ "components": [
+ {
+ "id": "bizppurio_tpl_footer_actions",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{extensionPointProps.channel === 'alimtalk'}}",
+ "props": {
+ "className": "flex gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "if": "{{(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '') && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}",
+ "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:cursor-not-allowed",
+ "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false) || !((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null,
+ "bz_tpl_modal.errors": null,
+ "bz_sms_modal.isSaving": true
+ }
+ },
+ {
+ "comment": "① 행 없음 + 본문 입력 → POST(생성). 검수 신청은 생성 응답의 id 로 이어간다.",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates",
+ "if": "{{!(_global.bz_tpl_modal?.id) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}",
+ "params": {
+ "method": "POST",
+ "body": {
+ "notification_type": "{{_global.bz_tpl_modal?.notification_type}}",
+ "content": "{{_global.bz_tpl_modal?.content}}",
+ "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}",
+ "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}",
+ "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}",
+ "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request",
+ "params": {
+ "method": "POST",
+ "body": {
+ "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "{{extensionPointProps.saveEndpoint}}",
+ "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}",
+ "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}",
+ "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}",
+ "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.",
+ "handler": "sequence",
+ "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ]
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "② 행 있음 + draft/rejected + 본문 입력 → PUT(내용 갱신).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}",
+ "if": "{{!!(_global.bz_tpl_modal?.id) && (['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "content": "{{_global.bz_tpl_modal?.content}}",
+ "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}",
+ "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}",
+ "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}",
+ "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request",
+ "params": {
+ "method": "POST",
+ "body": {
+ "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "{{extensionPointProps.saveEndpoint}}",
+ "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}",
+ "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}",
+ "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}",
+ "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.",
+ "handler": "sequence",
+ "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ]
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "flex-center gap-2 text-sm px-4 py-2 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",
+ "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false) || !((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "if": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}",
+ "props": {
+ "name": "spinner",
+ "className": "animate-spin text-sm"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{(_global.bz_tpl_modal?.isSaving ?? false) ? '$t:common.saving' : '$t:common.save'}}"
+ }
+ ],
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null,
+ "bz_tpl_modal.errors": null,
+ "bz_sms_modal.isSaving": true
+ }
+ },
+ {
+ "comment": "① 행 없음 + 본문 입력 → POST(생성). 검수 신청은 생성 응답의 id 로 이어간다.",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates",
+ "if": "{{!(_global.bz_tpl_modal?.id) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}",
+ "params": {
+ "method": "POST",
+ "body": {
+ "notification_type": "{{_global.bz_tpl_modal?.notification_type}}",
+ "content": "{{_global.bz_tpl_modal?.content}}",
+ "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}",
+ "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}",
+ "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}",
+ "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "{{extensionPointProps.saveEndpoint}}",
+ "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}",
+ "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}",
+ "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}",
+ "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.",
+ "handler": "sequence",
+ "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ]
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "② 행 있음 + draft/rejected + 본문 입력 → PUT(내용 갱신).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}",
+ "if": "{{!!(_global.bz_tpl_modal?.id) && (['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '')}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "content": "{{_global.bz_tpl_modal?.content}}",
+ "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}",
+ "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}",
+ "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}",
+ "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "{{extensionPointProps.saveEndpoint}}",
+ "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}",
+ "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}",
+ "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}",
+ "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.",
+ "handler": "sequence",
+ "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ]
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "③ 본문 미입력(SMS 만 설정) 또는 내용 잠금 상태(검수중/승인 등) → delivery upsert(발송 설정·SMS 본문만, 행 없으면 draft 생성).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_tpl_modal?.notification_type}}",
+ "if": "{{!((_global.bz_tpl_modal?.content?.templateContent ?? '').trim() !== '') || (!!(_global.bz_tpl_modal?.id) && !(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')))}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "alimtalk_enabled": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}",
+ "fallback_sms_enabled": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}",
+ "sms_only": "{{!!(_global.bz_sms_modal?.sms_only)}}",
+ "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "comment": "수신자 규칙 변경 시에만: 코어 채널 템플릿 PUT(코어 모달의 저장 계약과 동일 엔드포인트/본문 — subject·body 는 숨겨진 값 그대로).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "{{extensionPointProps.saveEndpoint}}",
+ "if": "{{(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "subject": "{{_global?.[extensionPointProps.stateKey]?.template?.subject}}",
+ "body": "{{_global?.[extensionPointProps.stateKey]?.template?.body}}",
+ "click_url": "{{_global?.[extensionPointProps.stateKey]?.template?.click_url ?? null}}",
+ "recipients": "{{_global?.[extensionPointProps.stateKey]?.recipients ?? []}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ },
+ {
+ "comment": "수신자 규칙 변경 없음: 코어 PUT 없이 마무리.",
+ "handler": "sequence",
+ "if": "{{!(JSON.stringify(_global?.[extensionPointProps.stateKey]?.recipients ?? []) !== JSON.stringify(_global?.[extensionPointProps.stateKey]?.template?.recipients ?? []))}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "{{extensionPointProps.refetchDataSourceId}}"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.section.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal"
+ }
+ ]
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_sms_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "bz_tpl_modal.errors": "{{(error.errors && typeof error.errors === 'object' && !error.errors.bizppurio_message) ? error.errors : null}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json
new file mode 100644
index 00000000..db0a8721
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/extensions/notification_template_form_sections.json
@@ -0,0 +1,2714 @@
+{
+ "extension_point": "notification_template_form_sections",
+ "comment": "코어 알림 템플릿 [편집] 모달 본문에 주입되는 비즈뿌리오 섹션(제품 결정 2026-08-23 — 행 하단 별도 버튼/모달 폐지, 편집 모달 통합). 3면(코어/게시판/이커머스)이 같은 extension_point 를 노출하므로 파일 1본으로 공유한다. ① 알림톡 템플릿: 승인 2단 배지 + 상태 액션(새로고침/신청 취소/승인 취소 후 수정/휴면 해제) + 작성 폼(draft/rejected) 또는 잠금 요약 ② 문자(SMS): 대체/단독 토글 + 로케일별 본문(검수 대상 아님). 저장은 notification_template_form_footer_actions 의 통합 [저장]이 담당한다. 상태는 _global.bz_tpl_modal / bz_sms_modal / bz_tpl_ui / bz_tpl_upload 에 담고 섹션 마운트(lifecycle.onMount) 시 요약 맵(bizppurioTemplates)으로 시딩한다. 코어 제목/본문 입력은 채널 메타 hidden_template_editor 로 코어 모달이 스스로 숨긴다.",
+ "priority": 320,
+ "components": [
+ {
+ "id": "bizppurio_tpl_sections",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{extensionPointProps.channel === 'alimtalk'}}",
+ "props": {
+ "className": "space-y-4"
+ },
+ "lifecycle": {
+ "onMount": [
+ {
+ "handler": "sequence",
+ "comment": "코어 [편집] 모달이 열릴 때(섹션 마운트) 요약 맵으로 상태를 시딩하고, 행이 있으면 상세(content)를 받아온다. 카테고리·발신프로필은 작성 셀렉트용(자격증명 미설정 422 는 suppress).",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_upload": {
+ "uploading": false,
+ "error": null
+ },
+ "bz_tpl_ui": {
+ "confirmCancelApproval": false
+ },
+ "bz_tpl_modal": {
+ "id": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id ?? null}}",
+ "notification_type": "{{extensionPointProps.definition?.type}}",
+ "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
+ "variables": "{{extensionPointProps.definition?.variables ?? []}}",
+ "status": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.status ?? 'draft'}}",
+ "has_content": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.has_content)}}",
+ "loading": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}",
+ "template_code": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.template_code ?? null}}",
+ "alimtalk_enabled": "{{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]) ? ((bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.alimtalk_enabled) !== false) : true}}",
+ "inspection_detail": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.inspection_detail ?? []}}",
+ "isSaving": false,
+ "error": null,
+ "errors": null,
+ "request_comment": "",
+ "sender_profile": "",
+ "content": {
+ "templateName": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
+ "templateMessageType": "BA",
+ "templateEmphasizeType": "NONE",
+ "templateContent": "{{(((extensionPointProps.definition?.templates ?? []).find(tpl => tpl.channel === 'alimtalk')?.body?.[$locale] ?? '').split('{').join('#{'))}}",
+ "templateExtra": "",
+ "templateHeader": "",
+ "templateTitle": "",
+ "templateSubtitle": "",
+ "templateImageName": "",
+ "templateImageUrl": "",
+ "categoryCode": "",
+ "buttons": [],
+ "quickReplies": [],
+ "templateItem": {
+ "list": [],
+ "summary": {
+ "title": "",
+ "description": ""
+ }
+ },
+ "templateItemHighlight": {
+ "title": "",
+ "description": "",
+ "imageUrl": ""
+ }
+ }
+ },
+ "bz_sms_modal": {
+ "notification_type": "{{extensionPointProps.definition?.type}}",
+ "notification_name": "{{extensionPointProps.definition?.name?.[$locale] ?? extensionPointProps.definition?.type}}",
+ "variables": "{{extensionPointProps.definition?.variables ?? []}}",
+ "body": "{{Object.assign({}, bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_body)}}",
+ "editLang": "{{$locale}}",
+ "fallback_sms_enabled": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.fallback_sms_enabled)}}",
+ "sms_only": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.sms_only)}}",
+ "isSaving": false
+ }
+ }
+ },
+ {
+ "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}",
+ "params": {
+ "method": "GET"
+ },
+ "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}",
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.status": "{{response.data?.template?.status}}",
+ "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}",
+ "bz_tpl_modal.loading": false,
+ "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}",
+ "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}",
+ "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.loading": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioCategories"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioProfiles"
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "onUnmount": [
+ {
+ "comment": "모달이 닫히면(언마운트) 폼 상태를 비운다 — 다음 오픈은 항상 시딩 전(READY=false)에서 시작해 이전 알림의 값이 보이거나 저장되는 창을 없앤다.",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal": null,
+ "bz_sms_modal": null
+ }
+ }
+ ]
+ },
+ "children": [
+ {
+ "id": "bizppurio_tpl_alimtalk_section",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "rounded-lg border border-teal-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4 space-y-3"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-between flex-wrap gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center flex-wrap gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-comment-dots",
+ "className": "text-base text-teal-600 dark:text-teal-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "H4",
+ "props": {
+ "className": "text-sm font-semibold text-gray-800 dark:text-gray-100"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.section.alimtalk_title"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "comment": "승인 여부 2단 배지(제품 결정 2026-08-23): 1차 = 승인됨/미승인, 2차(괄호) = 세부 상태(미작성·작성중·검수중·반려·휴면·중지·차단). approved 는 세부 없이 단독 표기.",
+ "props": {
+ "className": "{{'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ' + ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'stopped' || (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{$t('sirsoft-message_bizppurio.template.approval.' + ((((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') === 'approved' ? 'approved' : 'not_approved'))}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{(((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten') !== 'approved'}}",
+ "props": {
+ "className": "font-normal opacity-80"
+ },
+ "text": "{{'(' + $t('sirsoft-message_bizppurio.template.status.' + (((_global.bz_tpl_modal?.id) && (_global.bz_tpl_modal?.has_content)) ? (_global.bz_tpl_modal?.status ?? 'draft') : 'unwritten')) + ')'}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at}}",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.requested_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.requested_at ?? '').slice(5, 10)}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "if": "{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at}}",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.synced_on|date={{(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.last_synced_at ?? '').slice(5, 10)}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center flex-wrap gap-1.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "if": "{{!!(_global.bz_tpl_modal?.template_code)}}",
+ "props": {
+ "type": "button",
+ "className": "text-xs px-2.5 py-1 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:cursor-not-allowed",
+ "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/sync",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}",
+ "params": {
+ "method": "GET"
+ },
+ "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}",
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.status": "{{response.data?.template?.status}}",
+ "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}",
+ "bz_tpl_modal.loading": false,
+ "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}",
+ "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}",
+ "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.loading": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.row.synced_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "if": "{{_global.bz_tpl_modal?.status === 'requested'}}",
+ "props": {
+ "type": "button",
+ "className": "text-xs px-2.5 py-1 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:cursor-not-allowed",
+ "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/cancel-request",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.status": "draft"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "if": "{{_global.bz_tpl_modal?.status === 'dormant'}}",
+ "props": {
+ "type": "button",
+ "className": "text-xs px-2.5 py-1 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:cursor-not-allowed",
+ "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_release",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/release",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "comment": "상세(content) 조회 — 행 id 는 요약 맵(데이터소스)에서 읽는다. 같은 sequence 안에서 setState 로 막 넣은 _global 값은 다음 액션의 if/target 해석 시점에 반영되지 않는다(엔진: 디스패치 시점 컨텍스트).",
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id}}",
+ "params": {
+ "method": "GET"
+ },
+ "if": "{{!!(bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]?.id)}}",
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.status": "{{response.data?.template?.status}}",
+ "bz_tpl_modal.has_content": "{{!!(response.data?.template?.content)}}",
+ "bz_tpl_modal.loading": false,
+ "bz_tpl_modal.template_code": "{{response.data?.template?.template_code ?? null}}",
+ "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}",
+ "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.loading": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.row.released_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "if": "{{_global.bz_tpl_modal?.status === 'approved' && !(_global.bz_tpl_ui?.confirmCancelApproval ?? false)}}",
+ "props": {
+ "type": "button",
+ "className": "text-xs px-2.5 py-1 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:cursor-not-allowed border-amber-400 text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-gray-700"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_ui.confirmCancelApproval": true
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Label",
+ "comment": "알림톡 발송 사용 — 통합 [저장]으로 함께 저장된다(즉시 저장 아님).",
+ "props": {
+ "className": "inline-flex items-center gap-1.5 cursor-pointer select-none"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Checkbox",
+ "props": {
+ "autoBinding": false,
+ "checked": "{{!!(_global.bz_tpl_modal?.alimtalk_enabled ?? true)}}",
+ "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.alimtalk_enabled": "{{$event.target.checked}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-300"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.section.alimtalk_enabled"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "comment": "승인 취소 확인(인라인 — 모달 중첩 대신). 확정 시 kapi cancel_approval → draft 복귀 — 발송 게이트 즉시 차단(대체 SMS 는 SMS 설정대로 계속).",
+ "if": "{{(_global.bz_tpl_ui?.confirmCancelApproval ?? false) && _global.bz_tpl_modal?.status === 'approved'}}",
+ "props": {
+ "className": "p-3 rounded-lg bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex items-start gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-triangle-exclamation",
+ "className": "text-base text-amber-500 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-sm text-amber-900 dark:text-amber-100"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_tpl_modal?.notification_name ?? ''}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex justify-end gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs px-2.5 py-1 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:cursor-not-allowed",
+ "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ },
+ "text": "$t:common.cancel",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_ui.confirmCancelApproval": false
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "if": "{{_global.bz_tpl_modal?.status === 'approved'}}",
+ "props": {
+ "type": "button",
+ "className": "text-xs px-2.5 py-1 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50",
+ "disabled": "{{(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/cancel-approval",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.status": "draft",
+ "bz_tpl_ui.confirmCancelApproval": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast",
+ "type": "info"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioTemplates"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-exclamation",
+ "size": "sm",
+ "className": "text-red-600 dark:text-red-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner",
+ "props": {
+ "className": "text-xs font-medium text-red-800 dark:text-red-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}",
+ "item_var": "cm"
+ },
+ "props": {
+ "className": "text-xs text-red-700 dark:text-red-300 mt-1"
+ },
+ "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}"
+ }
+ ],
+ "props": {
+ "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-triangle-exclamation",
+ "size": "sm",
+ "className": "text-red-600 dark:text-red-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{_global.bz_tpl_modal?.error}}",
+ "props": {
+ "className": "text-xs text-red-800 dark:text-red-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "if": "{{_global.bz_tpl_modal?.error}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "if": "{{_global.bz_tpl_modal?.errors && typeof _global.bz_tpl_modal?.errors === 'object'}}",
+ "props": {
+ "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Ul",
+ "props": {
+ "className": "list-disc pl-5 space-y-0.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Li",
+ "iteration": {
+ "source": "{{Object.values(_global.bz_tpl_modal?.errors ?? {}).flat()}}",
+ "item_var": "msg"
+ },
+ "props": {
+ "className": "text-xs text-red-800 dark:text-red-200"
+ },
+ "text": "{{msg}}"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_tpl_loading",
+ "type": "basic",
+ "name": "Div",
+ "comment": "상세(content) 를 받아오는 동안 편집 폼을 열지 않는다 — 시딩 내용으로 저장되거나 입력이 상세 도착에 덮이는 경쟁 차단(Playwright 실측).",
+ "if": "{{!((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}",
+ "props": {
+ "className": "flex-center gap-2 text-xs text-gray-500 dark:text-gray-400 py-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "spinner",
+ "className": "animate-spin text-sm"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-message_bizppurio.template.section.loading"
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_tpl_locked",
+ "type": "basic",
+ "name": "Div",
+ "comment": "검수중/승인/휴면/중지/차단 — 내용 잠금. 요약만 보여주고 수정은 [신청 취소]/[승인 취소 후 수정] 뒤에만 가능(서버 가드 content_locked 와 동일 규칙).",
+ "if": "{{!(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}",
+ "props": {
+ "className": "space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-300 p-3 rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.section.locked_hint"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-300"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{$t('sirsoft-message_bizppurio.template.form.message_type') + ': ' + $t('sirsoft-message_bizppurio.template.form.templateMessageType_options.' + (_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA'))}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{$t('sirsoft-message_bizppurio.template.form.emphasize_type') + ': ' + $t('sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.' + (_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE'))}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "text-sm font-mono whitespace-pre-wrap p-3 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-100"
+ },
+ "text": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}"
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_tpl_editor",
+ "type": "basic",
+ "name": "Div",
+ "comment": "작성 폼(구 modal_bizppurio_template 본문을 그대로 이식). draft/rejected(또는 행 없음)에서만 노출.",
+ "if": "{{(['draft', 'rejected'].includes(_global.bz_tpl_modal?.status ?? 'draft')) && ((_global.bz_tpl_modal?.notification_type === extensionPointProps.definition?.type) && !(_global.bz_tpl_modal?.loading ?? false))}}",
+ "props": {
+ "className": "space-y-4"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "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.template.form.sender_profile"
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-full text-sm",
+ "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}",
+ "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.sender_profile": "{{$event.target.value}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
+ }
+ }
+ ]
+ },
+ {
+ "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.template.form.category *"
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-full text-sm",
+ "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}",
+ "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-1 sm:grid-cols-2 gap-3"
+ }
+ },
+ {
+ "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.template.form.name *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}",
+ "name": "bz_template_name",
+ "maxLength": 200
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateName": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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.template.form.message_type *"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형",
+ "props": {
+ "className": "flex flex-wrap gap-1.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "BA"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "EX"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "AD"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "MI"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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.template.form.emphasize_type *"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트",
+ "props": {
+ "className": "flex flex-wrap gap-1.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "NONE"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "TEXT"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "IMAGE"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "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.template.form.emphasize_title *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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.template.form.emphasize_subtitle *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-2 gap-3"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}"
+ },
+ {
+ "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.template.form.image *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "file",
+ "accept": ".jpg,.jpeg,.png",
+ "className": "block w-full text-xs text-gray-600 dark:text-gray-300"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "sirsoft-message_bizppurio.uploadTemplateImage",
+ "params": {
+ "stateTarget": "global",
+ "statePathUrl": "bz_tpl_modal.content.templateImageUrl",
+ "statePathName": "bz_tpl_modal.content.templateImageName",
+ "statePathStatus": "bz_tpl_upload"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.image_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading",
+ "props": {
+ "className": "text-xs text-amber-600 dark:text-amber-400"
+ },
+ "if": "{{_global.bz_tpl_upload?.uploading}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{_global.bz_tpl_upload?.error ?? ''}}",
+ "props": {
+ "className": "text-xs text-red-600 dark:text-red-400"
+ },
+ "if": "{{_global.bz_tpl_upload?.error}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}",
+ "props": {
+ "className": "text-xs text-green-700 dark:text-green-400 break-all"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}"
+ }
+ ],
+ "props": {
+ "className": "space-y-1"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-2 gap-1.5"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}",
+ "maxLength": 500
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_list"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",
+ "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_add"
+ }
+ ],
+ "props": {
+ "className": "flex-between mt-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}",
+ "item_var": "itm",
+ "index_var": "itmIdx"
+ },
+ "props": {
+ "className": "flex items-center gap-1.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 6,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title",
+ "value": "{{itm.title ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 23,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description",
+ "value": "{{itm.description ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item",
+ "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}"
+ }
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-trash-can",
+ "size": "sm"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium mt-2"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_summary"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 6,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 14,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-2 gap-1.5"
+ }
+ }
+ ],
+ "props": {
+ "className": "space-y-2"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "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.template.form.content *"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "name": "bz_template_content",
+ "rows": 6,
+ "maxLength": 1000,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono",
+ "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateContent": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.binding.variables_hint",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.variables ?? []}}",
+ "item_var": "v"
+ },
+ "props": {
+ "type": "button",
+ "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 hover:border-teal-400"
+ },
+ "text": "{{'#{' + (v.key ?? '') + '}'}}",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sirsoft-message_bizppurio.insertVariable",
+ "params": {
+ "variable": "{{v.key}}",
+ "target": "bz_template_content",
+ "stateTarget": "global",
+ "statePath": "bz_tpl_modal.content.templateContent"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex flex-wrap gap-1"
+ }
+ }
+ ],
+ "props": {
+ "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
+ },
+ "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}"
+ },
+ {
+ "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.template.form.extra *"
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "rows": 2,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ],
+ "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}"
+ },
+ {
+ "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.template.form.header"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}",
+ "maxLength": 16
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.header_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.buttons"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",
+ "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.button_add"
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}",
+ "item_var": "buttonsItem",
+ "index_var": "buttonsIdx"
+ },
+ "props": {
+ "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-40 text-xs",
+ "value": "{{buttonsItem.linkType ?? 'WL'}}",
+ "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name",
+ "value": "{{buttonsItem.name ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button",
+ "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}"
+ }
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-trash-can",
+ "size": "sm"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex items-center gap-1.5"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo",
+ "value": "{{buttonsItem.linkMo ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc",
+ "value": "{{buttonsItem.linkPc ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and",
+ "value": "{{buttonsItem.linkAnd ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios",
+ "value": "{{buttonsItem.linkIos ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number",
+ "value": "{{buttonsItem.telNumber ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'TN'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id",
+ "value": "{{buttonsItem.pluginId ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}"
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "space-y-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",
+ "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add"
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}",
+ "item_var": "quickRepliesItem",
+ "index_var": "quickRepliesIdx"
+ },
+ "props": {
+ "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-40 text-xs",
+ "value": "{{quickRepliesItem.linkType ?? 'WL'}}",
+ "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name",
+ "value": "{{quickRepliesItem.name ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply",
+ "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}"
+ }
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-trash-can",
+ "size": "sm"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex items-center gap-1.5"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo",
+ "value": "{{quickRepliesItem.linkMo ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc",
+ "value": "{{quickRepliesItem.linkPc ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and",
+ "value": "{{quickRepliesItem.linkAnd ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios",
+ "value": "{{quickRepliesItem.linkIos ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number",
+ "value": "{{quickRepliesItem.telNumber ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'TN'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id",
+ "value": "{{quickRepliesItem.pluginId ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}"
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "space-y-2"
+ }
+ },
+ {
+ "id": "bizppurio_tpl_request_comment",
+ "type": "basic",
+ "name": "Div",
+ "comment": "검수자 전달 의견 — kapi template/request 의 comment(≤500). 카카오 심사 가이드가 요구하는 변수 예시값 등을 검수자에게 전할 유일한 통로(제품 결정 2026-08-23 §18.7). 신청 시에만 전송되며 행에 저장되지 않는다.",
+ "children": [
+ {
+ "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.template.form.request_comment"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{(_global.bz_tpl_modal?.request_comment ?? '').length}}/500",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "name": "bz_request_comment",
+ "rows": 3,
+ "maxLength": 500,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.request_comment_placeholder",
+ "value": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.request_comment": "{{$event.target.value}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.request_comment_hint"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "id": "bizppurio_tpl_sms_section",
+ "type": "basic",
+ "name": "Div",
+ "comment": "문자(SMS) — 검수 대상 아님(카카오 검수는 알림톡 템플릿만). 대체 SMS/SMS 단독 토글 + 로케일별 본문. 통합 [저장]으로 delivery 필드와 함께 저장된다.",
+ "props": {
+ "className": "rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-3"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-between flex-wrap gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center gap-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-comment-sms",
+ "className": "text-base text-gray-500 dark:text-gray-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "H4",
+ "props": {
+ "className": "text-sm font-semibold text-gray-800 dark:text-gray-100"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.section.sms_title"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex-center flex-wrap gap-3"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "inline-flex items-center gap-1.5 cursor-pointer select-none"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Checkbox",
+ "props": {
+ "autoBinding": false,
+ "checked": "{{!!(_global.bz_sms_modal?.fallback_sms_enabled)}}",
+ "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.fallback_sms_enabled": "{{$event.target.checked}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-300"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.fallback_sms"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "inline-flex items-center gap-1.5 cursor-pointer select-none"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Checkbox",
+ "props": {
+ "autoBinding": false,
+ "checked": "{{!!(_global.bz_sms_modal?.sms_only)}}",
+ "className": "h-3.5 w-3.5 rounded border-gray-300 dark:border-gray-600 text-teal-600"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.sms_only": "{{$event.target.checked}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-300"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.row.sms_only"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-gray-700"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.sms.hint"
+ },
+ {
+ "id": "bz_sms_lang_tabs",
+ "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex border-b border-gray-200 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "iteration": {
+ "source": "{{$locales}}",
+ "item_var": "loc"
+ },
+ "props": {
+ "type": "button",
+ "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none"
+ },
+ "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.editLang": "{{loc}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "name": "bz_sms_body",
+ "rows": 4,
+ "maxLength": 2000,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.binding.variables_hint",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "iteration": {
+ "source": "{{_global.bz_sms_modal?.variables ?? []}}",
+ "item_var": "v"
+ },
+ "props": {
+ "type": "button",
+ "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 hover:border-teal-400"
+ },
+ "text": "{{'#{' + (v.key ?? '') + '}'}}",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sirsoft-message_bizppurio.insertVariable",
+ "params": {
+ "variable": "{{v.key}}",
+ "target": "bz_sms_body",
+ "stateTarget": "global",
+ "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex flex-wrap gap-1"
+ }
+ }
+ ],
+ "props": {
+ "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
+ },
+ "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts
new file mode 100644
index 00000000..49f776d1
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/handlers/uploadTemplateImage.test.ts
@@ -0,0 +1,161 @@
+// e2e:allow 업로드 in-flight 경합·모달 재시딩은 브라우저에서 재현하려면 네트워크 지연을
+// 인위적으로 만들어야 한다. 브라우저가 담당하는 축(업로드 성공 → URL 기입 → 저장)은
+// tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 맡고, 여기서는 경합 자체를 고정한다.
+/**
+ * uploadTemplateImage 핸들러 — in-flight 경합 가드 (#597 라운드 5 R2·R3)
+ *
+ * 라운드 5 이전에는 다음 두 경로가 열려 있었다:
+ * - 업로드가 진행 중인데 파일을 다시 고르면 두 요청이 겹치고, 먼저 끝난 쪽이
+ * `uploading:false` 를 써서 저장 버튼 잠금이 풀린다. 나중에 끝나는 업로드는
+ * 저장 이후에 templateImageUrl 을 덮는다.
+ * - 업로드 중 모달을 닫고 다른 알림의 모달을 열면(취소는 의도적으로 잠기지 않는다),
+ * 먼저 시작한 업로드의 결과가 새 모달의 폼에 기입된다. 실패 경로에서는 새 모달이
+ * 방금 시딩한 이미지 값이 지워진다.
+ *
+ * 둘 다 예외도 경고도 남기지 않으므로, 여기서 고정하지 않으면 되돌려도 red 가 나지 않는다.
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { uploadTemplateImageHandler } from '../../handlers/uploadTemplateImage';
+
+/** 전역 상태를 흉내내는 최소 스토어 (dot 경로 setState + getGlobal) */
+function makeStore(initial: Record = {}) {
+ const state: Record = { bz_tpl_modal: { content: {} }, bz_tpl_upload: {}, ...initial };
+
+ const setGlobal = vi.fn((updates: Record) => {
+ for (const [path, value] of Object.entries(updates)) {
+ const keys = path.split('.');
+ let cur: any = state;
+ for (const k of keys.slice(0, -1)) {
+ if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
+ cur = cur[k];
+ }
+ cur[keys[keys.length - 1]] = value;
+ }
+ });
+
+ return { state, setGlobal, getGlobal: () => state };
+}
+
+/** change 이벤트 컨텍스트(파일 1개 선택) */
+function fileContext(name = 'a.png') {
+ const input = { files: [new File(['x'], name, { type: 'image/png' })], value: name } as unknown as HTMLInputElement;
+
+ return { context: { event: { target: input } as unknown as Event }, input };
+}
+
+const ACTION = {
+ handler: 'sirsoft-message_bizppurio.uploadTemplateImage',
+ params: {
+ stateTarget: 'global',
+ statePathUrl: 'bz_tpl_modal.content.templateImageUrl',
+ statePathName: 'bz_tpl_modal.content.templateImageName',
+ statePathStatus: 'bz_tpl_upload',
+ },
+} as any;
+
+let store: ReturnType;
+
+beforeEach(() => {
+ store = makeStore();
+ (window as any).G7Core = {
+ state: { setGlobal: store.setGlobal, getGlobal: store.getGlobal },
+ t: (k: string) => k,
+ };
+ localStorage.setItem('auth_token', 'tkn');
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ delete (window as any).G7Core;
+});
+
+describe('uploadTemplateImage — in-flight 경합 가드', () => {
+ /**
+ * @effects upload_rejects_concurrent_selection_while_in_flight
+ */
+ it('업로드가 진행 중이면 두 번째 파일 선택을 무시한다(요청 1회)', async () => {
+ let release: (v: any) => void = () => {};
+ const fetchMock = vi.fn(() => new Promise((r) => { release = r; }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ const first = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
+ // 첫 요청이 아직 열려 있는 상태에서 두 번째 선택
+ await uploadTemplateImageHandler(ACTION, fileContext('two.png').context as any);
+
+ expect(fetchMock, '겹친 업로드가 나가면 먼저 끝난 쪽이 저장 잠금을 풀어 버린다').toHaveBeenCalledTimes(1);
+
+ release({ ok: true, json: async () => ({ success: true, data: { url: 'https://k/1.png' } }) });
+ await first;
+
+ expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('https://k/1.png');
+ expect(store.state.bz_tpl_upload.uploading).toBe(false);
+ });
+
+ /**
+ * @effects upload_result_discarded_when_modal_reseeded
+ */
+ it('응답 도착 전 모달이 다시 시딩되면 성공 결과를 폼에 쓰지 않는다', async () => {
+ let release: (v: any) => void = () => {};
+ vi.stubGlobal('fetch', vi.fn(() => new Promise((r) => { release = r; })));
+
+ const pending = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
+ expect(store.state.bz_tpl_upload.uploading).toBe(true);
+
+ // 다른 알림의 모달을 여는 지점이 하는 일: 상태 리시드
+ store.setGlobal({ 'bz_tpl_upload': { uploading: false, error: null } });
+ store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/keep.png' });
+
+ release({ ok: true, json: async () => ({ success: true, data: { url: 'https://k/stale.png' } }) });
+ await pending;
+
+ expect(store.state.bz_tpl_modal.content.templateImageUrl,
+ '이전 모달의 업로드 결과가 지금 열린 모달의 폼을 덮으면 안 된다').toBe('https://k/keep.png');
+ });
+
+ /**
+ * @effects upload_result_discarded_when_modal_reseeded
+ */
+ it('응답 도착 전 모달이 다시 시딩되면 실패 결과도 새 모달의 값을 지우지 않는다', async () => {
+ let release: (v: any) => void = () => {};
+ vi.stubGlobal('fetch', vi.fn(() => new Promise((r) => { release = r; })));
+
+ const pending = uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
+
+ store.setGlobal({ 'bz_tpl_upload': { uploading: false, error: null } });
+ store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/keep.png' });
+
+ release({ ok: false, json: async () => ({ success: false, message: '실패' }) });
+ await pending;
+
+ expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('https://k/keep.png');
+ expect(store.state.bz_tpl_upload.error, '새 모달에 남의 실패 배너가 뜨면 안 된다').toBeNull();
+ });
+
+ it('업로드가 끝나면 다음 선택을 다시 받는다(가드가 영구 잠기지 않는다)', async () => {
+ const fetchMock = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ success: true, data: { url: 'https://k/1.png' } }),
+ }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ await uploadTemplateImageHandler(ACTION, fileContext('one.png').context as any);
+ await uploadTemplateImageHandler(ACTION, fileContext('two.png').context as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('성공 응답인데 url 이 비면 실패로 처리하고 이미지 값을 비운다', async () => {
+ store.setGlobal({ 'bz_tpl_modal.content.templateImageUrl': 'https://k/old.png' });
+ vi.stubGlobal('fetch', vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ success: true, data: { url: ' ' } }),
+ })));
+
+ await uploadTemplateImageHandler(ACTION, fileContext().context as any);
+
+ expect(store.state.bz_tpl_modal.content.templateImageUrl).toBe('');
+ expect(store.state.bz_tpl_upload.error).toBeTruthy();
+ expect(store.state.bz_tpl_upload.uploading).toBe(false);
+ });
+});
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..f4dc9b40
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/helpers.ts
@@ -0,0 +1,139 @@
+/**
+ * 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼
+ *
+ * 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));
+}
+
+/**
+ * `{{...}}` 단일 바인딩 조건식을 주어진 자유변수 스코프로 실제 평가합니다.
+ *
+ * 문자열 포함/동일성 단언(`toContain`/`toBe`)은 조건을 잘못 고쳐도 기대 문자열을 함께
+ * 고치면 통과하므로 회귀를 잡지 못한다. 식을 그대로 실행해 결과 boolean 을 본다.
+ *
+ * @param expr `{{ ... }}` 형태의 조건식(감싸지 않은 식도 허용)
+ * @param scope 식이 참조하는 자유변수 (예: `{ _global: {...} }`)
+ * @returns 평가 결과의 boolean 캐스팅
+ */
+export function evalBinding(expr: string, scope: Record): boolean {
+ const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
+ const names = Object.keys(scope);
+ // eslint-disable-next-line no-new-func
+ const fn = new Function(...names, `return (${body});`);
+
+ return Boolean(fn(...names.map((n) => scope[n])));
+}
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..7491f5aa
--- /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'(발송중)이어도 검수 라벨로 대체 표시한다(제품 결정 —
+ // "발송중" 문구 자체가 검수 모드에서는 오해 소지라 배지 병기가 아니라 라벨 교체).
+ 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_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..77b9e716
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_board_ecommerce.test.tsx
@@ -0,0 +1,121 @@
+// e2e:allow 3면 패리티는 정규화 문자열 대조 Vitest 로 잠근다(#597 재작성) — 라이프사이클 전이
+// 브라우저 흐름은 코어 면 대표로 tests/Playwright/specs/admin/template-lifecycle.spec.ts 가 담당.
+/**
+ * 게시판·이커머스 알림 설정 '비즈뿌리오' 통합 탭 오버레이 — 3면 패리티 검증 (#597)
+ *
+ * 시나리오 매니페스트 exclusions 근거: "3면은 동일 오버레이 패턴(파일 diff 는 id·target 뿐)
+ * — 라이프사이클 전이는 코어 면으로 대표하고 면 axis 는 렌더 패리티(Vitest)로 잠근다".
+ *
+ * 검증 방식: comment 필드 제거 후 JSON 문자열에서 면 고유 토큰(target_layout·target_id·
+ * 컴포넌트 id 접두)만 코어 형으로 치환하면 코어 오버레이와 완전 동일해야 한다.
+ * 반대로 데이터소스 id 는 치환 없이도 3면 동일해야 한다 — notification_row_footer.json 과
+ * notification_template_form_sections.json(전역 매칭, 3면 공유 1본)이 이 이름들을 공유 참조하기 때문이다.
+ * 편집 UI(작성 폼·SMS·저장)는 코어 [편집] 모달의 extension_point 에 주입되는 공유 1본이라
+ * 면 패리티 대상이 아니다(제품 결정 2026-08-23).
+ *
+ * 세 오버레이가 전문 동일하다는 것이 board/ecommerce 면의 화면 효과를 떠받치는 근거다 —
+ * core 면에서 단언한 것들(탭 통합·행 하단 UI·업로드 잠금·SMS 언어 탭)이 두 면에도 있다는
+ * 보장은 이 패리티뿐이고, 그래서 이 파일이 사라지면 두 면은 미측정으로 남는다.
+ *
+ * @effects bizppurio_tab_replaces_sms_and_alimtalk_tabs, row_footer_shows_status_summary_only
+*/
+
+import { describe, it, expect } from 'vitest';
+import coreOverlay from '../../../extensions/notification_tab_core.json';
+import boardOverlay from '../../../extensions/notification_tab_board.json';
+import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json';
+import { type AnyNode } from './helpers';
+
+type OverlayFixture = {
+ label: string;
+ overlay: typeof boardOverlay;
+ targetLayout: string;
+ targetId: string;
+ idPrefix: string;
+};
+
+const FIXTURES: OverlayFixture[] = [
+ {
+ label: '게시판',
+ overlay: boardOverlay,
+ targetLayout: 'admin_board_settings',
+ targetId: 'board_notif_channel_content',
+ idPrefix: 'bizppurio_board_',
+ },
+ {
+ label: '이커머스',
+ overlay: ecommerceOverlay,
+ targetLayout: 'admin_ecommerce_settings',
+ targetId: 'ecommerce_notif_channel_content',
+ idPrefix: 'bizppurio_ecommerce_',
+ },
+];
+
+/** comment 필드를 재귀 제거한다(설명문은 면마다 달라도 되는 유일한 자유 축). */
+const stripComments = (node: unknown): unknown => {
+ if (Array.isArray(node)) return node.map(stripComments);
+ if (node && typeof node === 'object') {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(node as Record)) {
+ if (k === 'comment') continue;
+ out[k] = stripComments(v);
+ }
+ return out;
+ }
+ return node;
+};
+
+const normalizedCore = JSON.stringify(stripComments(coreOverlay));
+
+/** 3면 공유 이름(치환 없이 동일해야 하는 축) */
+const SHARED_DATA_SOURCE_IDS = ['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles'];
+// 모달·전역 상태(bz_*)는 오버레이가 아니라 3면 공유 1본(notification_template_form_sections.json /
+// _footer_actions.json)이 소유한다(제품 결정 2026-08-23 — 편집 모달 통합). 오버레이는 모달을 등록하지 않는다.
+
+describe.each(FIXTURES)('$label 오버레이 — 3면 패리티', ({ label, overlay, targetLayout, targetId, idPrefix }) => {
+ 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('면 고유 토큰(target_layout/target_id/id 접두)만 치환하면 코어 오버레이와 완전 동일하다(comment 제외)', () => {
+ const normalized = JSON.stringify(stripComments(overlay))
+ .split(targetLayout).join('admin_settings')
+ .split(targetId).join('notif_channel_content')
+ .split(idPrefix).join('bizppurio_');
+ expect(normalized, `${label} 오버레이가 코어와 구조 불일치`).toBe(normalizedCore);
+ });
+
+ it('데이터소스 id 3종이 코어와 완전 동일하다(순서 포함 — 행 footer 공유 참조)', () => {
+ const ids = ((overlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
+ expect(ids).toEqual(SHARED_DATA_SOURCE_IDS);
+ });
+
+ it('오버레이는 모달을 등록하지 않고, 면 전용 전역 상태(bz_board_* 등)도 만들지 않는다', () => {
+ expect((overlay as { modals?: AnyNode[] }).modals, `${label} 오버레이 modals`).toBeUndefined();
+ const raw = JSON.stringify(overlay);
+ expect(raw).not.toContain(`bz_${idPrefix.replace('bizppurio_', '')}`);
+ expect(raw).not.toContain('openModal');
+ });
+});
+
+describe('3면 패리티 — 공유 축 교차 검증', () => {
+ it('코어 오버레이도 동일한 공유 데이터소스 id 집합을 갖고 모달은 없다(패리티 기준점 고정)', () => {
+ const dsIds = ((coreOverlay as { data_sources?: Array<{ id: string }> }).data_sources ?? []).map((d) => d.id);
+ expect(dsIds).toEqual(SHARED_DATA_SOURCE_IDS);
+ expect((coreOverlay as { modals?: AnyNode[] }).modals).toBeUndefined();
+ });
+
+ it('세 오버레이의 init_actions(탭 진입 요약 맵 조회)가 comment 제외 완전 동일하다', () => {
+ const pick = (o: unknown) => JSON.stringify(stripComments((o as { init_actions?: unknown }).init_actions ?? []));
+ expect(pick(boardOverlay)).toBe(pick(coreOverlay));
+ expect(pick(ecommerceOverlay)).toBe(pick(coreOverlay));
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx
new file mode 100644
index 00000000..e490fb74
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/notification_tab_lifecycle.test.tsx
@@ -0,0 +1,841 @@
+// e2e:allow 구조 단언 + 조건식 실평가 Vitest(#597) — 브라우저 흐름은
+// tests/Playwright/specs/admin/template-lifecycle*.spec.ts 가 담당(발송 인프라 의존 축은 별도 계획).
+//
+// 노출 조건·저장 분기는 문자열 동일성이 아니라 `new Function` 실평가로 판정한다
+// (§14.2 T7) — 리터럴 비교는 조건을 잘못 고쳐도 기대값을 함께 고치면 green 이라 회귀를
+// 잡지 못한다.
+/**
+ * 알림 설정 '비즈뿌리오' 통합 탭 — [편집] 모달 통합 UI 구조 검증 (#597, 제품 결정 2026-08-23)
+ *
+ * @effects row_footer_shows_status_summary_only, approval_badge_two_tier, edit_modal_hosts_alimtalk_and_sms_sections,
+ * unified_save_chain_alimtalk_then_core_recipients, compose_modal_switches_conditional_fields_by_type,
+ * sms_modal_saves_body_via_delivery_upsert, sms_modal_edits_body_per_locale_tab,
+ * inspection_request_carries_reviewer_comment
+ *
+ * 파일 구성(플러그인 resources/extensions):
+ * - notification_tab_core/board/ecommerce.json (Overlay): 상태 배너(injections) + 안내 박스 + data_sources 3종.
+ * 모달은 더 이상 등록하지 않는다(작성/SMS/승인취소/반려 모달 폐지).
+ * - notification_row_footer.json (ExtensionPoint notification_definition_row_footer): 행 하단 **상태 요약만**
+ * (승인 2단 배지 + 신청/동기화일 + SMS 설정 요약). 버튼 없음 — 조작은 코어 [편집] 모달로 일원화.
+ * - notification_template_form_sections.json (ExtensionPoint notification_template_form_sections): 코어 [편집]
+ * 모달 본문에 주입되는 알림톡 템플릿 섹션(작성 폼/잠금 요약/상태 액션) + 문자(SMS) 섹션. 3면 공유 1본.
+ * - notification_template_form_footer_actions.json (ExtensionPoint notification_template_form_footer_actions):
+ * 통합 [저장] / [저장 후 검수 신청] — 알림톡 upsert(또는 delivery upsert) → (검수 신청) → 수신자 규칙이
+ * 바뀐 경우에만 코어 PUT → 마무리.
+ *
+ * 라이프사이클: 미작성(unwritten=행 없음/내용 없음) → 작성(draft) → 검수 신청(requested)
+ * → 승인(approved) / 반려(rejected) / 휴면(dormant). 발송 판정은 DB 가 유일한 근거.
+ */
+
+import { describe, it, expect } from 'vitest';
+import coreOverlay from '../../../extensions/notification_tab_core.json';
+import boardOverlay from '../../../extensions/notification_tab_board.json';
+import ecommerceOverlay from '../../../extensions/notification_tab_ecommerce.json';
+import footer from '../../../extensions/notification_row_footer.json';
+import sections from '../../../extensions/notification_template_form_sections.json';
+import footerActions from '../../../extensions/notification_template_form_footer_actions.json';
+import ko from '../../../lang/ko.json';
+import en from '../../../lang/en.json';
+import { findById, findAllByName, type AnyNode, evalBinding } from './helpers';
+
+const overlay = coreOverlay;
+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;
+const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode;
+const actionsRoot = { children: (footerActions as { components?: AnyNode[] }).components ?? [] } as AnyNode;
+
+/** 요약 맵의 def.type 행 접근 경로(행 하단·섹션이 공유하는 데이터 근거) */
+const MAP_PATH = "bizppurioTemplates?.data?.templates?.[extensionPointProps.definition?.type]";
+
+/** text 키로 버튼 노드를 찾는다. */
+const findButtonByTextKey = (root: AnyNode, key: string): AnyNode | undefined =>
+ findAllByName(root, 'Button').find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`);
+
+/** JSON 텍스트에서 $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(/'$/, '')),
+ ])).filter((k) => !k.endsWith('.'));
+};
+
+const resolve = (root: unknown, path: string): unknown =>
+ path.split('.').slice(1).reduce((acc, seg) => (acc as Record)?.[seg], root);
+
+/** 문자열 리프 전수 수집 */
+const collectStrings = (node: unknown, acc: string[] = []): string[] => {
+ if (typeof node === 'string') acc.push(node);
+ else if (Array.isArray(node)) node.forEach((c) => collectStrings(c, acc));
+ else if (node && typeof node === 'object') Object.values(node).forEach((v) => collectStrings(v, acc));
+ return acc;
+};
+
+/** 모든 액션 노드(handler 보유 객체)를 재귀 수집한다. */
+const collectActions = (node: unknown, acc: AnyNode[] = []): AnyNode[] => {
+ if (Array.isArray(node)) node.forEach((c) => collectActions(c, acc));
+ else if (node && typeof node === 'object') {
+ const rec = node as AnyNode;
+ if (typeof rec.handler === 'string') acc.push(rec);
+ Object.values(rec).forEach((v) => collectActions(v, acc));
+ }
+ return acc;
+};
+
+/**
+ * `{{...}}` 텍스트/조건 식을 실제로 평가한다. `$t` 는 키를 그대로 돌려주는 스텁이라
+ * 평가 결과로 "어느 i18n 키가 선택됐는가" 를 판정할 수 있다.
+ */
+const evalExpr = (expr: string, scope: Record): unknown => {
+ const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
+ const names = Object.keys(scope);
+ // eslint-disable-next-line no-new-func
+ const fn = new Function('$t', '$locale', ...names, `return (${body});`);
+ return fn((k: string) => k, 'ko', ...names.map((n) => scope[n]));
+};
+
+/** 요약 맵 행 상태 픽스처 (bizppurioTemplates.data.templates[type]) */
+const ROWS: Record | undefined> = {
+ '행 없음': undefined,
+ '행만 있고 내용 없음': { id: 1, has_content: false, status: 'draft' },
+ draft: { id: 1, has_content: true, status: 'draft', template_code: null },
+ requested: { id: 1, has_content: true, status: 'requested', template_code: 'g7_a_1' },
+ approved: { id: 1, has_content: true, status: 'approved', template_code: 'g7_a_1' },
+ rejected: { id: 1, has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] },
+ dormant: { id: 1, has_content: true, status: 'dormant', template_code: 'g7_a_1' },
+ stopped: { id: 1, has_content: true, status: 'stopped', template_code: 'g7_a_1' },
+ blocked: { id: 1, has_content: true, status: 'blocked', template_code: 'g7_a_1' },
+};
+const mapScope = (row: Record | undefined) => ({
+ bizppurioTemplates: { data: { templates: row ? { welcome: row } : {} } },
+ extensionPointProps: { definition: { type: 'welcome' }, activeChannel: 'alimtalk', channel: 'alimtalk' },
+});
+
+describe('lifecycle UI — 파일 분리(Overlay vs ExtensionPoint 3종)', () => {
+ it('overlay 는 target_layout=admin_settings 이고 extension_point·modals 키가 없다(모달 폐지)', () => {
+ expect((overlay as { target_layout?: string }).target_layout).toBe('admin_settings');
+ expect((overlay as Record).extension_point).toBeUndefined();
+ for (const [label, o] of [['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay]] as const) {
+ expect((o as Record).modals, `${label} 오버레이는 모달을 등록하지 않는다`).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('섹션·푸터 액션은 코어 편집 모달의 extension_point 2종에 주입된다(3면 공유 1본)', () => {
+ expect((sections as { extension_point?: string }).extension_point).toBe('notification_template_form_sections');
+ expect((footerActions as { extension_point?: string }).extension_point).toBe('notification_template_form_footer_actions');
+ expect((sections as Record).target_layout).toBeUndefined();
+ expect((footerActions as Record).target_layout).toBeUndefined();
+ });
+
+ it('섹션·푸터 액션 루트는 alimtalk 채널 템플릿을 편집할 때만 노출된다(메일·사이트내 알림 모달 무오염)', () => {
+ for (const root of [findById(sectionsRoot, 'bizppurio_tpl_sections'), findById(actionsRoot, 'bizppurio_tpl_footer_actions')]) {
+ expect(root).toBeTruthy();
+ const cond = (root as { if?: string }).if ?? '';
+ expect(evalBinding(cond, { extensionPointProps: { channel: 'alimtalk' } })).toBe(true);
+ expect(evalBinding(cond, { extensionPointProps: { channel: 'mail' } })).toBe(false);
+ expect(evalBinding(cond, { extensionPointProps: { channel: '' } })).toBe(false);
+ }
+ });
+});
+
+describe('lifecycle UI — 데이터소스 3종', () => {
+ const sources = ((overlay as { data_sources?: Array> }).data_sources ?? []);
+
+ it('bizppurioTemplates / bizppurioCategories / bizppurioProfiles 3종을 등록한다', () => {
+ expect(sources.map((d) => d.id)).toEqual(['bizppurioTemplates', 'bizppurioCategories', 'bizppurioProfiles']);
+ });
+
+ it('3종 모두 auto_fetch:false — 설정 페이지 다른 탭에서 자동 호출되지 않는다', () => {
+ for (const ds of sources) expect(ds.auto_fetch, `${ds.id} auto_fetch`).toBe(false);
+ });
+
+ it('요약 맵은 GET templates/map 이고 탭 진입 init_actions 로만 조회된다', () => {
+ const map = sources.find((d) => d.id === 'bizppurioTemplates');
+ expect(map?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/map');
+ expect(map?.method).toBe('GET');
+ const inits = (overlay as { init_actions?: AnyNode[] }).init_actions ?? [];
+ const refetch = inits.find((a) => a.handler === 'refetchDataSource');
+ expect((refetch?.params as { dataSourceId?: string })?.dataSourceId).toBe('bizppurioTemplates');
+ });
+
+ it('카테고리·발신프로필은 실패를 suppress 하고 load_failed fallback 을 둔다(자격증명 미설정 422 방어)', () => {
+ for (const id of ['bizppurioCategories', 'bizppurioProfiles']) {
+ const ds = sources.find((d) => d.id === id);
+ expect(JSON.stringify(ds?.errorHandling), `${id} suppress`).toContain('suppress');
+ const fallback = (ds?.fallback as { data?: Record })?.data ?? {};
+ expect(fallback.load_failed, `${id} load_failed`).toBe(true);
+ }
+ });
+});
+
+describe('lifecycle 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('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 만으로 노출된다(회귀 유지)', () => {
+ 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('알림톡 탭 상시 안내 박스가 [편집] 모달 통합 안내(tab_guide)를 노출한다', () => {
+ const guide = findById(bannerRoot, 'bizppurio_alimtalk_guide');
+ expect(guide).toBeTruthy();
+ expect((guide as { if?: string }).if).toContain("=== 'alimtalk'");
+ expect(JSON.stringify(guide)).toContain('template.tab_guide');
+ expect((ko as Record).template.tab_guide).toContain('[편집]');
+ });
+});
+
+describe('lifecycle UI — 행 하단은 상태 요약만 (버튼 0)', () => {
+ const row = findById(footerRoot, 'bizppurio_row_lifecycle') as AnyNode;
+
+ it('행 UI 는 activeChannel === alimtalk 일 때만 노출된다', () => {
+ expect(row).toBeTruthy();
+ expect(evalBinding((row as { if?: string }).if ?? '', { extensionPointProps: { activeChannel: 'alimtalk' } })).toBe(true);
+ expect(evalBinding((row as { if?: string }).if ?? '', { extensionPointProps: { activeChannel: 'mail' } })).toBe(false);
+ });
+
+ it('행 하단에는 버튼·체크박스·apiCall 이 없다 — 조작은 코어 [편집] 모달로 일원화', () => {
+ expect(findAllByName(row, 'Button')).toHaveLength(0);
+ expect(findAllByName(row, 'Checkbox')).toHaveLength(0);
+ expect(findAllByName(row, 'Toggle')).toHaveLength(0);
+ expect(collectActions(row).filter((a) => a.handler === 'apiCall')).toHaveLength(0);
+ expect(JSON.stringify(row)).not.toContain('openModal');
+ });
+
+ it('SMS 요약 줄은 대체/단독 사용 여부와 서버 미리보기(has_sms_body / sms_body_preview)를 읽는다', () => {
+ const raw = JSON.stringify(row);
+ expect(raw).toContain(`${MAP_PATH}?.fallback_sms_enabled`);
+ expect(raw).toContain(`${MAP_PATH}?.sms_only`);
+ expect(raw).toContain(`${MAP_PATH}?.has_sms_body === true`);
+ expect(raw).toContain(`${MAP_PATH}?.sms_body_preview`);
+ expect(raw).toContain('template.row.sms_body_missing');
+ expect(raw).not.toContain('.slice(0, 40)');
+ });
+
+ it('SMS 상태 3종은 문구가 아니라 배지다 — 사용=초록/미사용=회색(승인 배지와 같은 모양), 본문 미설정=외곽선 칩 (제품 결정 2026-08-23)', () => {
+ const APPROVED_SET = 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200';
+ const GRAY_SET = 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300';
+ for (const [id, field] of [['bizppurio_row_sms_fallback_badge', 'fallback_sms_enabled'], ['bizppurio_row_sms_only_badge', 'sms_only']] as const) {
+ const badge = findById(row, id) as AnyNode;
+ expect(badge, id).toBeTruthy();
+ const cls = String((badge.props as { className: string }).className);
+ const on = String(evalExpr(cls, mapScope({ id: 1, has_content: true, status: 'draft', [field]: true })));
+ const off = String(evalExpr(cls, mapScope({ id: 1, has_content: true, status: 'draft', [field]: false })));
+ expect(on).toContain('rounded');
+ expect(on).toContain('px-2 py-0.5');
+ expect(on).toContain(APPROVED_SET);
+ expect(off).toContain(GRAY_SET);
+ expect(evalExpr(String(badge.text), mapScope({ id: 1, [field]: true }))).toBe('sirsoft-message_bizppurio.template.row.on');
+ expect(evalExpr(String(badge.text), mapScope({ id: 1, [field]: false }))).toBe('sirsoft-message_bizppurio.template.row.off');
+ // 라벨(대체 SMS / SMS 단독)은 배지 앞 형제
+ const group = findById(row, id.replace('_badge', '')) as AnyNode;
+ expect((group.children as AnyNode[])[0].text).toMatch(/template\.row\.(fallback_sms|sms_only)$/);
+ }
+ const missing = findById(row, 'bizppurio_row_sms_body_missing') as AnyNode;
+ expect(missing).toBeTruthy();
+ expect(String((missing.props as { className: string }).className)).toContain('border border-gray-300');
+ expect(missing.text).toBe('$t:sirsoft-message_bizppurio.template.row.sms_body_missing');
+ // 평문 "라벨: 값" 조립은 남지 않는다
+ expect(JSON.stringify(row)).not.toContain("+ ': ' +");
+ });
+});
+
+describe('lifecycle UI — 승인 여부 2단 배지 실평가 (행 하단 + 섹션 헤더 동형)', () => {
+ /** 배지 = 1차(승인됨/미승인) Span + 2차(괄호 세부) Span 을 품은 Span */
+ const badgeOf = (root: AnyNode): AnyNode => {
+ const badge = findAllByName(root, 'Span').find((s) => String((s.comment as string) ?? '').includes('승인 여부 2단 배지'));
+ expect(badge, '2단 배지 노드').toBeTruthy();
+ return badge as AnyNode;
+ };
+
+ const EXPECTED: Record = {
+ '행 없음': { primary: 'not_approved', detail: 'unwritten' },
+ '행만 있고 내용 없음': { primary: 'not_approved', detail: 'unwritten' },
+ draft: { primary: 'not_approved', detail: 'draft' },
+ requested: { primary: 'not_approved', detail: 'requested' },
+ approved: { primary: 'approved', detail: null },
+ rejected: { primary: 'not_approved', detail: 'rejected' },
+ dormant: { primary: 'not_approved', detail: 'dormant' },
+ stopped: { primary: 'not_approved', detail: 'stopped' },
+ blocked: { primary: 'not_approved', detail: 'blocked' },
+ };
+
+ /** 섹션 헤더 배지는 상세 GET 으로 갱신되는 모달 상태를 근거로 한다(요약 맵은 모달 안 액션 직후 stale — 실측) */
+ const modalScope = (row: Record | undefined) => ({
+ _global: { bz_tpl_modal: row ? { id: row.id, status: row.status, has_content: row.has_content } : { id: null } },
+ });
+
+ it.each([
+ ['행 하단', footerRoot, mapScope],
+ ['섹션 헤더', sectionsRoot, modalScope],
+ ] as const)('%s 배지가 상태 9종을 승인됨 / 미승인(세부) 로 표기한다', (_label, root, scopeOf) => {
+ const badge = badgeOf(root);
+ const [primary, detail] = badge.children as AnyNode[];
+ for (const [state, row] of Object.entries(ROWS)) {
+ const scope = scopeOf(row) as Record;
+ const want = EXPECTED[state];
+ expect(evalExpr(String(primary.text), scope), `${state} 1차`).toBe(`sirsoft-message_bizppurio.template.approval.${want.primary}`);
+ const detailVisible = evalBinding(String(detail.if), scope);
+ expect(detailVisible, `${state} 2차 노출`).toBe(want.detail !== null);
+ if (want.detail) {
+ expect(evalExpr(String(detail.text), scope), `${state} 2차`).toBe(`(sirsoft-message_bizppurio.template.status.${want.detail})`);
+ }
+ // 색상 클래스는 승인/검수중/반려/휴면/중지·차단/기타 6계열 — 승인됨만 green
+ const cls = String(evalExpr(String((badge.props as { className: string }).className), scope));
+ expect(cls.includes('bg-green-100'), `${state} green`).toBe(state === 'approved');
+ }
+ });
+});
+
+describe('lifecycle UI — [편집] 모달 섹션: 시딩(onMount) + 편집/잠금 분기 실평가', () => {
+ const root = findById(sectionsRoot, 'bizppurio_tpl_sections') as AnyNode;
+ const onMount = ((root.lifecycle as { onMount?: AnyNode[] })?.onMount ?? []);
+ const steps = ((onMount[0]?.params as { actions?: AnyNode[] })?.actions ?? []);
+
+ it('마운트 시 sequence 1개: 상태 시딩 → 상세 GET(행 있을 때) → 카테고리·발신프로필 조회', () => {
+ expect(onMount).toHaveLength(1);
+ expect(onMount[0].handler).toBe('sequence');
+ expect(steps.map((s) => s.handler)).toEqual(['setState', 'apiCall', 'refetchDataSource', 'refetchDataSource']);
+ const seed = steps[0].params as Record;
+ for (const key of ['bz_tpl_modal', 'bz_sms_modal', 'bz_tpl_upload', 'bz_tpl_ui']) expect(seed[key], key).toBeTruthy();
+ expect((steps[2].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioCategories');
+ expect((steps[3].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioProfiles');
+ });
+
+ it('상세 GET 은 요약 맵의 행 id 로 판정·호출한다(같은 sequence 의 setState 값은 다음 액션 if 에 반영되지 않는다 — 실측)', () => {
+ const detail = steps[1];
+ expect(detail.target).toBe(`/api/plugins/sirsoft-message_bizppurio/admin/templates/{{${MAP_PATH}?.id}}`);
+ expect(String(detail.if)).not.toContain('_global.bz_tpl_modal');
+ expect(evalBinding(String(detail.if), mapScope(ROWS.draft))).toBe(true);
+ expect(evalBinding(String(detail.if), mapScope(ROWS['행만 있고 내용 없음']))).toBe(true);
+ expect(evalBinding(String(detail.if), mapScope(ROWS['행 없음']))).toBe(false);
+ const onSuccess = JSON.stringify(detail.onSuccess);
+ expect(onSuccess).toContain('bz_tpl_modal.content');
+ expect(onSuccess).toContain('bz_tpl_modal.status');
+ expect(onSuccess).toContain('bz_tpl_modal.has_content');
+ });
+
+ it('시딩값: SMS 본문·토글은 요약 맵에서, 알림톡 본문 프리필은 코어 alimtalk 템플릿 body 에서(#{var} 변환)', () => {
+ const seed = steps[0].params as { bz_tpl_modal: Record; bz_sms_modal: Record };
+ expect(seed.bz_sms_modal.body).toBe(`{{Object.assign({}, ${MAP_PATH}?.sms_body)}}`);
+ expect(seed.bz_sms_modal.fallback_sms_enabled).toBe(`{{!!(${MAP_PATH}?.fallback_sms_enabled)}}`);
+ expect(seed.bz_sms_modal.sms_only).toBe(`{{!!(${MAP_PATH}?.sms_only)}}`);
+ expect(seed.bz_sms_modal.editLang).toBe('{{$locale}}');
+ expect(seed.bz_tpl_modal.has_content).toBe(`{{!!(${MAP_PATH}?.has_content)}}`);
+ expect(String((seed.bz_tpl_modal.content as Record).templateContent)).toContain("tpl.channel === 'alimtalk'");
+ expect(String((seed.bz_tpl_modal.content as Record).templateContent)).toContain(".split('{').join('#{')");
+ });
+
+ const editor = findById(sectionsRoot, 'bizppurio_tpl_editor') as AnyNode;
+ const locked = findById(sectionsRoot, 'bizppurio_tpl_locked') as AnyNode;
+ const loadingNotice = findById(sectionsRoot, 'bizppurio_tpl_loading') as AnyNode;
+ /** 자기 알림 유형으로 시딩이 끝나고 상세 GET 도 끝난 상태(READY) */
+ const readyScope = (modal: Record) => ({
+ _global: { bz_tpl_modal: { notification_type: 'welcome', loading: false, ...modal } },
+ extensionPointProps: { definition: { type: 'welcome' }, channel: 'alimtalk' },
+ });
+
+ it.each([
+ ['draft', true], ['rejected', true], [undefined, true],
+ ['requested', false], ['approved', false], ['dormant', false], ['stopped', false], ['blocked', false],
+ ])('상태 %s → 작성 폼 노출=%s, 잠금 요약은 그 반대 (READY 상태에서)', (status, editable) => {
+ const scope = readyScope(status === undefined ? {} : { status });
+ expect(evalBinding(String(editor.if), scope)).toBe(editable);
+ expect(evalBinding(String(locked.if), scope)).toBe(!editable);
+ });
+
+ it('READY(자기 유형 시딩 + 상세 GET 완료) 전에는 작성 폼·잠금 요약이 모두 숨고 로딩 안내만 보인다 — 시딩 전/이전 알림 상태로 저장되는 경쟁 차단', () => {
+ expect(loadingNotice).toBeTruthy();
+ const seed = (steps[0].params as { bz_tpl_modal: Record }).bz_tpl_modal;
+ expect(seed.loading).toBe(`{{!!(${MAP_PATH}?.id)}}`);
+ expect(seed.notification_type).toBe('{{extensionPointProps.definition?.type}}');
+ expect(JSON.stringify(steps[1].onSuccess)).toContain('"bz_tpl_modal.loading":false');
+ expect(JSON.stringify(steps[1].onError)).toContain('"bz_tpl_modal.loading":false');
+
+ const NOT_READY = [
+ ['시딩 전(상태 없음)', { _global: {}, extensionPointProps: { definition: { type: 'welcome' } } }],
+ ['이전 알림의 상태', { _global: { bz_tpl_modal: { notification_type: 'reset_password', status: 'draft', loading: false } }, extensionPointProps: { definition: { type: 'welcome' } } }],
+ ['상세 GET 진행 중', { _global: { bz_tpl_modal: { notification_type: 'welcome', status: 'draft', loading: true } }, extensionPointProps: { definition: { type: 'welcome' } } }],
+ ] as const;
+ for (const [label, scope] of NOT_READY) {
+ expect(evalBinding(String(editor.if), scope as Record), `${label}: 폼`).toBe(false);
+ expect(evalBinding(String(locked.if), scope as Record), `${label}: 잠금 요약`).toBe(false);
+ expect(evalBinding(String(loadingNotice.if), scope as Record), `${label}: 로딩 안내`).toBe(true);
+ }
+ for (const status of ['draft', 'requested', 'approved']) {
+ const ready = readyScope({ status });
+ expect(evalBinding(String(loadingNotice.if), ready)).toBe(false);
+ expect(evalBinding(String(editor.if), ready) || evalBinding(String(locked.if), ready), `${status}: READY 후 둘 중 하나`).toBe(true);
+ }
+ });
+
+ it('모달이 닫히면(언마운트) 폼 상태를 비워 다음 오픈이 시딩 전에서 시작한다', () => {
+ const root = findById(sectionsRoot, 'bizppurio_tpl_sections') as AnyNode;
+ const onUnmount = ((root.lifecycle as { onUnmount?: AnyNode[] })?.onUnmount ?? []);
+ expect(onUnmount).toHaveLength(1);
+ expect(onUnmount[0].handler).toBe('setState');
+ const params = onUnmount[0].params as Record;
+ expect(params.bz_tpl_modal).toBeNull();
+ expect(params.bz_sms_modal).toBeNull();
+ });
+
+ it('작성 폼에는 구 작성 모달의 필드 전부가 이식됐다(유형·강조·본문·버튼·바로연결·업로드)', () => {
+ const raw = JSON.stringify(editor);
+ for (const needle of ['templateMessageType', 'templateEmphasizeType', 'bz_template_content', 'buttons', 'quickReplies', 'sirsoft-message_bizppurio.uploadTemplateImage', 'templateImageUrl', 'templateItem']) {
+ expect(raw, needle).toContain(needle);
+ }
+ });
+
+ it('상태 액션 4종의 노출 조건이 상태별로 정확히 평가된다', () => {
+ const BUTTONS: Record) => boolean> = {
+ 'template.row.btn_refresh': (s) => Boolean(s.template_code),
+ 'template.row.btn_cancel_request': (s) => s.status === 'requested',
+ 'template.row.btn_release': (s) => s.status === 'dormant',
+ 'template.row.btn_edit_approved': (s) => s.status === 'approved',
+ };
+ for (const [key, expected] of Object.entries(BUTTONS)) {
+ const btn = findButtonByTextKey(sectionsRoot, key);
+ expect(btn, key).toBeTruthy();
+ for (const [state, row] of Object.entries(ROWS)) {
+ if (!row) continue;
+ const scope = { _global: { bz_tpl_modal: { status: row.status, template_code: row.template_code ?? null }, bz_tpl_ui: { confirmCancelApproval: false } } };
+ expect(evalBinding(String(btn?.if), scope), `${key} @ ${state}`).toBe(expected(row));
+ }
+ }
+ });
+
+ it('승인 취소는 인라인 확인 박스(모달 중첩 없음) — 확정 시 POST .../cancel-approval 후 draft 로 되돌린다', () => {
+ const confirmBtn = findButtonByTextKey(sectionsRoot, 'template.cancel_approval.btn_confirm');
+ expect(confirmBtn).toBeTruthy();
+ const raw = JSON.stringify(confirmBtn);
+ expect(raw).toContain(`/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/cancel-approval`);
+ expect(raw).toContain('"bz_tpl_modal.status":"draft"');
+ expect(JSON.stringify(sectionsRoot)).not.toContain('openModal');
+ expect(JSON.stringify(sectionsRoot)).toContain('template.cancel_approval.warning');
+ });
+
+ it('발송 토글(알림톡 사용·대체 SMS·SMS 단독)은 상태에만 쓰고 즉시 저장하지 않는다(통합 저장 원칙)', () => {
+ const boxes = findAllByName(sectionsRoot, 'Checkbox');
+ const bound = boxes.map((c) => String((c.props as { checked?: string })?.checked ?? ''));
+ expect(bound.some((b) => b.includes('bz_tpl_modal?.alimtalk_enabled'))).toBe(true);
+ expect(bound.some((b) => b.includes('bz_sms_modal?.fallback_sms_enabled'))).toBe(true);
+ expect(bound.some((b) => b.includes('bz_sms_modal?.sms_only'))).toBe(true);
+ for (const c of boxes) {
+ expect((c.props as { autoBinding?: boolean }).autoBinding).toBe(false);
+ const change = (c.actions as AnyNode[]).find((a) => a.type === 'change') as AnyNode;
+ expect(change.handler, '토글은 setState 만').toBe('setState');
+ }
+ });
+
+ it('반려 배너는 inspection_detail 스냅샷을 반복 렌더한다(반려 사유 모달 대체)', () => {
+ const raw = JSON.stringify(sectionsRoot);
+ expect(raw).toContain('template.form.rejected_banner');
+ expect(raw).toContain('_global.bz_tpl_modal?.inspection_detail ?? []');
+ });
+});
+
+describe('lifecycle UI — 통합 [저장] 체인 (푸터 액션)', () => {
+ const buttons = findAllByName(actionsRoot, 'Button');
+ const saveBtn = buttons.find((b) => !b.text && JSON.stringify(b).includes('$t:common.save')) as AnyNode;
+ const requestBtn = findButtonByTextKey(actionsRoot, 'template.form.btn_save_request') as AnyNode;
+
+ it('[저장] + [저장 후 검수 신청] 두 버튼이며, 검수 신청은 수정 가능 상태 + 본문 입력이 있을 때만 노출된다', () => {
+ expect(buttons).toHaveLength(2);
+ expect(saveBtn).toBeTruthy();
+ expect(requestBtn).toBeTruthy();
+ expect(saveBtn.if).toBeUndefined();
+ const cond = String(requestBtn.if);
+ const sc = (modal: Record) => ({ _global: { bz_tpl_modal: { notification_type: 'welcome', loading: false, ...modal } }, extensionPointProps: { definition: { type: 'welcome' } } });
+ expect(evalBinding(cond, sc({ status: 'draft', content: { templateContent: '본문' } }))).toBe(true);
+ expect(evalBinding(cond, sc({ status: 'rejected', content: { templateContent: '본문' } }))).toBe(true);
+ expect(evalBinding(cond, sc({ content: { templateContent: '본문' } })), '행 없음(신규)').toBe(true);
+ expect(evalBinding(cond, sc({ status: 'draft', content: { templateContent: ' ' } })), '공백 본문').toBe(false);
+ expect(evalBinding(cond, sc({ status: 'requested', content: { templateContent: '본문' } }))).toBe(false);
+ expect(evalBinding(cond, sc({ status: 'approved', content: { templateContent: '본문' } }))).toBe(false);
+ expect(evalBinding(cond, sc({ status: 'draft', loading: true, content: { templateContent: '본문' } })), '상세 GET 전').toBe(false);
+ });
+
+ it('두 버튼 모두 클릭 if 재진입 가드 + isSaving/uploading disabled 바인딩을 갖는다', () => {
+ for (const b of [saveBtn, requestBtn]) {
+ const click = (b.actions as AnyNode[])[0];
+ expect(click.type).toBe('click');
+ expect(click.if).toMatch(/!\(_global\.bz_tpl_modal\?\.isSaving \?\? false\)/);
+ const disabled = String((b.props as { disabled?: string }).disabled);
+ const ep = { definition: { type: 'welcome' } };
+ expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: true, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(true);
+ expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: true } }, extensionPointProps: ep })).toBe(true);
+ expect(evalBinding(disabled, { _global: { bz_tpl_modal: { isSaving: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(false);
+ const ready = { _global: { bz_tpl_modal: { isSaving: false, loading: false, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } };
+ const notReady = { _global: { bz_tpl_modal: { isSaving: false, loading: true, notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } };
+ const otherType = { _global: { bz_tpl_modal: { isSaving: false, loading: false, notification_type: 'reset_password' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: { definition: { type: 'welcome' } } };
+ expect(evalBinding(disabled, ready), 'READY 후 저장 열림').toBe(false);
+ expect(evalBinding(disabled, notReady), '상세 GET 전 저장 잠금').toBe(true);
+ expect(evalBinding(disabled, otherType), '이전 알림 상태로는 저장 잠금').toBe(true);
+ expect(evalBinding(String(click.if), notReady), '상세 GET 전 클릭 가드').toBe(false);
+ expect(evalBinding(String(click.if), ready), 'READY 후 클릭 허용').toBe(true);
+ }
+ });
+
+ /** 저장 상태 매트릭스 → 실행돼야 하는 분기 번호(①POST ②PUT ③delivery) */
+ const MATRIX: Array<[string, Record, 1 | 2 | 3]> = [
+ ['행 없음 + 본문 입력', { content: { templateContent: '본문' } }, 1],
+ ['행 없음 + 본문 없음(SMS 만)', { content: { templateContent: '' } }, 3],
+ ['draft + 본문 입력', { id: 1, status: 'draft', content: { templateContent: '본문' } }, 2],
+ ['rejected + 본문 입력', { id: 1, status: 'rejected', content: { templateContent: '본문' } }, 2],
+ ['draft + 본문 없음', { id: 1, status: 'draft', content: { templateContent: '' } }, 3],
+ ['requested(잠금) + 본문 있음', { id: 1, status: 'requested', content: { templateContent: '본문' } }, 3],
+ ['approved(잠금) + 본문 있음', { id: 1, status: 'approved', content: { templateContent: '본문' } }, 3],
+ ['dormant(잠금)', { id: 1, status: 'dormant', content: { templateContent: '본문' } }, 3],
+ ];
+
+ it.each(MATRIX)('[저장] %s → 분기 정확히 하나만 실행된다', (_label, modal, want) => {
+ const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions;
+ expect(steps[0].handler).toBe('setState');
+ const branches = steps.slice(1);
+ expect(branches).toHaveLength(3);
+ const fired = branches.map((b) => evalBinding(String(b.if), { _global: { bz_tpl_modal: modal } }));
+ expect(fired.filter(Boolean)).toHaveLength(1);
+ expect(fired.indexOf(true) + 1).toBe(want);
+ });
+
+ it('분기 엔드포인트/메서드: ① POST templates ② PUT templates/{id} ③ PUT templates/delivery/{type} — delivery 필드 4종은 셋 다 싣는다', () => {
+ const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1);
+ expect([steps[0].target, (steps[0].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates', 'POST']);
+ expect([steps[1].target, (steps[1].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}', 'PUT']);
+ expect([steps[2].target, (steps[2].params as { method: string }).method]).toEqual(['/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_tpl_modal?.notification_type}}', 'PUT']);
+ for (const s of steps) {
+ const body = (s.params as { body: Record }).body;
+ for (const k of ['alimtalk_enabled', 'fallback_sms_enabled', 'sms_only', 'sms_body']) expect(body[k], `${s.comment} ${k}`).toBeTruthy();
+ expect(body.sms_body).toBe('{{Object.assign({}, _global.bz_sms_modal?.body)}}');
+ }
+ expect((steps[0].params as { body: Record }).body.content).toBeTruthy();
+ expect((steps[1].params as { body: Record }).body.content).toBeTruthy();
+ expect((steps[2].params as { body: Record }).body.content, '잠금 상태에서는 content 를 보내지 않는다(422 content_locked 회피)').toBeUndefined();
+ });
+
+ it('각 분기 성공 후: 수신자 규칙이 바뀐 경우에만 코어 PUT(extensionPointProps.saveEndpoint) → 마무리, 아니면 바로 마무리', () => {
+ const steps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1);
+ for (const s of steps) {
+ const tail = s.onSuccess as AnyNode[];
+ expect(tail).toHaveLength(2);
+ const [corePut, skip] = tail;
+ expect(corePut.handler).toBe('apiCall');
+ expect(corePut.target).toBe('{{extensionPointProps.saveEndpoint}}');
+ expect((corePut.params as { method: string }).method).toBe('PUT');
+ expect(skip.handler).toBe('sequence');
+ const changed = { _global: { notification_template_form_modal: { recipients: [{ type: 'role', role_ids: [1] }], template: { recipients: [{ type: 'trigger_user' }] } } }, extensionPointProps: { stateKey: 'notification_template_form_modal' } };
+ const same = { _global: { notification_template_form_modal: { recipients: [{ type: 'trigger_user' }], template: { recipients: [{ type: 'trigger_user' }] } } }, extensionPointProps: { stateKey: 'notification_template_form_modal' } };
+ expect(evalBinding(String(corePut.if), changed)).toBe(true);
+ expect(evalBinding(String(skip.if), changed)).toBe(false);
+ expect(evalBinding(String(corePut.if), same)).toBe(false);
+ expect(evalBinding(String(skip.if), same)).toBe(true);
+ // 코어 PUT 본문은 코어 모달의 저장 계약과 같은 4필드, 값은 extensionPointProps.stateKey 로 간접 참조
+ const body = (corePut.params as { body: Record }).body;
+ expect(Object.keys(body).sort()).toEqual(['body', 'click_url', 'recipients', 'subject']);
+ for (const v of Object.values(body)) expect(v).toContain('_global?.[extensionPointProps.stateKey]');
+ // 마무리: 요약 맵 + 면별 정의 목록 재조회 → 토스트 → closeModal
+ for (const fin of [corePut.onSuccess as AnyNode[], (skip.params as { actions: AnyNode[] }).actions]) {
+ const handlers = fin.map((a) => a.handler);
+ expect(handlers).toEqual(['setState', 'refetchDataSource', 'refetchDataSource', 'toast', 'closeModal']);
+ expect((fin[1].params as { dataSourceId: string }).dataSourceId).toBe('bizppurioTemplates');
+ expect((fin[2].params as { dataSourceId: string }).dataSourceId).toBe('{{extensionPointProps.refetchDataSourceId}}');
+ }
+ }
+ });
+
+ it('[저장 후 검수 신청]: 알림톡 upsert(①/②) 성공 → POST .../request → 코어 PUT(조건) → 마무리; delivery 전용 분기 ③ 은 없다', () => {
+ const steps = ((requestBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1);
+ expect(steps).toHaveLength(2);
+ const [create, update] = steps;
+ const createReq = (create.onSuccess as AnyNode[])[0];
+ expect(createReq.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request');
+ const updateReq = (update.onSuccess as AnyNode[])[0];
+ expect(updateReq.target).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request');
+ for (const req of [createReq, updateReq]) {
+ expect((req.params as { method: string }).method).toBe('POST');
+ expect((req.onSuccess as AnyNode[])[0].target).toBe('{{extensionPointProps.saveEndpoint}}');
+ expect(JSON.stringify(req.onSuccess)).toContain('template.form.requested_toast');
+ }
+ });
+
+ it('[저장 후 검수 신청]의 request 2노드는 검수자 전달 의견(bz_tpl_modal.request_comment)을 comment 로 싣는다 (#597 §18.7)', () => {
+ const steps = ((requestBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1);
+ const reqs = steps.map((s) => (s.onSuccess as AnyNode[])[0]);
+ expect(reqs).toHaveLength(2);
+ for (const req of reqs) {
+ const body = (req.params as { body: Record }).body;
+ expect(Object.keys(body)).toEqual(['comment']);
+ expect(evalExpr(body.comment, { _global: { bz_tpl_modal: { request_comment: '변수 예시: #{name}=홍길동' } } })).toBe('변수 예시: #{name}=홍길동');
+ expect(evalExpr(body.comment, { _global: { bz_tpl_modal: {} } }), '미입력 시 빈 문자열(서버가 미전달로 처리)').toBe('');
+ }
+ // 일반 [저장] 3분기에는 request 호출 자체가 없으므로 comment 도 없다
+ const saveSteps = ((saveBtn.actions as AnyNode[])[0].params as { actions: AnyNode[] }).actions.slice(1);
+ expect(JSON.stringify(saveSteps)).not.toContain('/request"');
+ });
+
+ it('실패 시 isSaving 해제 + 사유(bizppurio_message 우선)·필드 오류 객체를 상태에 싣는다(토스트가 아니라 섹션 배너가 표시)', () => {
+ const fails = collectActions(actionsRoot).filter((a) => a.handler === 'apiCall').map((a) => JSON.stringify(a.onError));
+ expect(fails.length).toBeGreaterThan(0);
+ for (const f of fails) {
+ expect(f).toContain('"bz_tpl_modal.isSaving":false');
+ expect(f).toContain('error.errors?.bizppurio_message ?? error.message');
+ expect(f).toContain('"bz_tpl_modal.errors"');
+ }
+ const raw = JSON.stringify(sectionsRoot);
+ expect(raw).toContain("Object.values(_global.bz_tpl_modal?.errors ?? {}).flat()");
+ });
+});
+
+describe('lifecycle UI — 검수자 전달 의견 입력란 (#597 §18.7, 제품 결정 2026-08-23)', () => {
+ const editor = findById(sectionsRoot, 'bizppurio_tpl_editor') as AnyNode;
+ const block = findById(editor, 'bizppurio_tpl_request_comment') as AnyNode;
+
+ it('작성 폼(bizppurio_tpl_editor) 안에만 있다 — 잠금 요약에는 없다', () => {
+ expect(block).toBeTruthy();
+ expect(findById(findById(sectionsRoot, 'bizppurio_tpl_locked') as AnyNode, 'bizppurio_tpl_request_comment')).toBeFalsy();
+ });
+
+ it('Textarea(bz_request_comment, ≤500) 가 bz_tpl_modal.request_comment 를 읽고 쓰며 카운터가 /500 이다', () => {
+ const ta = findAllByName(block, 'Textarea')[0] as AnyNode;
+ const props = ta.props as Record;
+ expect(props.name).toBe('bz_request_comment');
+ expect(props.maxLength).toBe(500);
+ expect(props.value).toBe("{{_global.bz_tpl_modal?.request_comment ?? ''}}");
+ const change = (ta.actions as AnyNode[])[0];
+ expect(change.type).toBe('change');
+ expect((change.params as Record)['bz_tpl_modal.request_comment']).toBe('{{$event.target.value}}');
+ const counter = findAllByName(block, 'Span').map((s) => String(s.text)).find((t) => t.endsWith('/500')) as string;
+ expect(evalExpr(counter.replace('/500', ''), { _global: { bz_tpl_modal: { request_comment: '12345' } } })).toBe(5);
+ });
+
+ it('모달 시딩이 request_comment 를 빈 문자열로 초기화한다(이전 알림의 의견 이월 금지)', () => {
+ const seeds = collectActions(sectionsRoot)
+ .filter((a) => a.handler === 'setState' && (a.params as Record)?.bz_tpl_modal);
+ expect(seeds.length).toBeGreaterThan(0);
+ for (const s of seeds) {
+ expect(((s.params as Record).bz_tpl_modal as Record).request_comment).toBe('');
+ }
+ });
+
+ it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => {
+ for (const key of ['request_comment', 'request_comment_placeholder', 'request_comment_hint']) {
+ expect(resolve(ko, `sirsoft-message_bizppurio.template.form.${key}`), `ko ${key}`).toBeTruthy();
+ expect(resolve(en, `sirsoft-message_bizppurio.template.form.${key}`), `en ${key}`).toBeTruthy();
+ }
+ });
+
+ /** children 경로의 조상 체인(루트→부모). id 가 없으면 null. */
+ const ancestorsOf = (root: AnyNode, id: string, anc: AnyNode[] = []): AnyNode[] | null => {
+ for (const c of (root.children as AnyNode[] | undefined) ?? []) {
+ if (c.id === id) return anc.concat(root);
+ const r = ancestorsOf(c, id, anc.concat(root));
+ if (r) return r;
+ }
+ return null;
+ };
+
+ it('바로연결 블록(헤더+목록) 바로 뒤 형제로 놓이고, 라벨+[바로연결 추가] flex 행 안에 끼지 않는다 (회귀: 화면 검수 지적 2026-08-23)', () => {
+ const chain = ancestorsOf(editor, 'bizppurio_tpl_request_comment') as AnyNode[];
+ expect(chain).toBeTruthy();
+ const parent = chain[chain.length - 1];
+ const siblings = parent.children as AnyNode[];
+ const idx = siblings.findIndex((c) => c.id === 'bizppurio_tpl_request_comment');
+ const prev = siblings[idx - 1];
+ // 직전 형제 = 바로연결 블록 전체(추가 버튼 + 목록 iteration 을 모두 품는다)
+ expect(JSON.stringify(prev)).toContain('template.form.quick_reply_add');
+ expect(JSON.stringify(prev)).toContain('quickRepliesItem');
+ // 의견 블록의 조상 어디에도 flex 행이 없다(헤더 행 내부 삽입이면 라벨이 세로로 찌그러진다)
+ for (const a of chain) {
+ expect(String((a.props as { className?: string } | undefined)?.className ?? ''), `ancestor ${a.id ?? a.name}`).not.toMatch(/\bflex\b/);
+ }
+ });
+});
+
+describe('lifecycle UI — 코어 계약 준수 + i18n 정합', () => {
+ it('플러그인 파일 어디에도 코어 엔드포인트·코어 모달 상태 키 리터럴이 없다(전부 extensionPointProps 간접 참조)', () => {
+ const all = JSON.stringify([overlay, footer, sections, footerActions]);
+ expect(all).not.toContain('/api/admin/notification-templates/');
+ expect(all).not.toContain('notification_template_form_modal');
+ expect(all).not.toContain('modal_notification_template_form');
+ });
+
+ it('참조하는 모든 플러그인 i18n 정적 키가 ko·en 에 존재한다', () => {
+ const keys = [overlay, footer, sections, footerActions].flatMap(collectPluginKeys);
+ expect(keys.length).toBeGreaterThan(0);
+ for (const key of Array.from(new Set(keys))) {
+ expect(resolve(ko, key), `ko 누락: ${key}`).toBeTruthy();
+ expect(resolve(en, key), `en 누락: ${key}`).toBeTruthy();
+ }
+ });
+
+ it("동적 접두 'template.status.' 8종 + 'template.approval.' 2종이 ko·en 에 모두 존재한다", () => {
+ const statuses = ['unwritten', 'draft', 'requested', 'approved', 'rejected', 'stopped', 'blocked', 'dormant'];
+ for (const dict of [ko, en] as Array>) {
+ expect(Object.keys(dict.template.status).sort()).toEqual([...statuses].sort());
+ expect(Object.keys(dict.template.approval).sort()).toEqual(['approved', 'not_approved']);
+ expect(Object.keys(dict.template.row)).toEqual(expect.arrayContaining(['on', 'off', 'sms_label', 'alimtalk_disabled']));
+ }
+ });
+
+ it("동적 접두 'templates.link_type.' 가족: 작성 폼이 사용하는 링크 유형 코드가 ko·en 에 모두 존재한다", () => {
+ const used = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP'];
+ for (const dict of [ko, en] as Array>) {
+ for (const code of used) expect(dict.templates.link_type[code], `link_type.${code}`).toBeTruthy();
+ }
+ });
+});
+
+describe('lifecycle UI — 바인딩 브레이스 위생 (회귀: 프리필 끝 `}}` 잔재)', () => {
+ const FILES = [
+ ['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay],
+ ['footer', footer], ['sections', sections], ['footerActions', footerActions],
+ ] as const;
+
+ it('문자열 값 어디에도 4연속 브레이스(}}}}·{{{{)가 없다', () => {
+ for (const [name, json] of FILES) {
+ const offenders = collectStrings(json).filter((v) => v.includes('}}}}') || v.includes('{{{{'));
+ expect(offenders, `${name} 브레이스 잔재: ${offenders.join(' | ')}`).toEqual([]);
+ }
+ });
+
+ it('표현식 $t 를 2-인자(파라미터 객체)로 호출하지 않는다 — 엔진은 단일 인자만 지원', () => {
+ for (const [name, json] of FILES) {
+ const offenders = collectStrings(json).filter((v) => /\$t\([^)]*,\s*\{/.test(v));
+ expect(offenders, `${name} 2-인자 $t 호출: ${offenders.join(' | ')}`).toEqual([]);
+ }
+ });
+});
+
+/**
+ * @effects upload_in_progress_locks_save_buttons_not_cancel
+ */
+describe('lifecycle UI — 이미지 업로드 상태 배선', () => {
+ it('업로드 진행 중에는 통합 저장 계열 버튼이 잠긴다(취소는 코어 버튼이라 플러그인이 잠글 수 없다)', () => {
+ const buttons = findAllByName(actionsRoot, 'Button');
+ expect(buttons).toHaveLength(2);
+ for (const b of buttons) {
+ const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? '');
+ const ep = { definition: { type: 'welcome' } };
+ expect(evalBinding(disabled, { _global: { bz_tpl_modal: { notification_type: 'welcome' }, bz_tpl_upload: { uploading: true } }, extensionPointProps: ep })).toBe(true);
+ expect(evalBinding(disabled, { _global: { bz_tpl_modal: { notification_type: 'welcome' }, bz_tpl_upload: { uploading: false } }, extensionPointProps: ep })).toBe(false);
+ }
+ expect(JSON.stringify(actionsRoot)).not.toContain('$t:common.cancel');
+ });
+
+ it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => {
+ expect(JSON.stringify(sectionsRoot)).toContain("_global.bz_tpl_upload?.error ?? ''");
+ });
+});
+
+describe('lifecycle UI — SMS 본문 다국어', () => {
+ const raw = JSON.stringify(sectionsRoot);
+
+ it('SMS 섹션은 로케일 탭을 제공하고 본문을 로케일별로 읽는다', () => {
+ const tabs = findById(sectionsRoot, 'bz_sms_lang_tabs');
+ expect(tabs, 'SMS 본문 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy();
+ expect(JSON.stringify(tabs)).toContain('{{$locales}}');
+ expect(raw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]');
+ });
+
+ it('변수 삽입 대상 경로가 현재 편집 로케일을 가리킨다', () => {
+ expect(raw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)");
+ });
+});
+
+describe('compose form — 유형 전환·반복 입력 실평가 (#597 §14.2 T7 / §6.2)', () => {
+ const evalWithModal = (expr: string, modal: Record): boolean =>
+ evalBinding(expr, { _global: { bz_tpl_modal: modal, bz_tpl_upload: {} } });
+
+ const conditionalBlocks = (): Record => {
+ const found: Record = {};
+ const walk = (node: unknown): void => {
+ if (Array.isArray(node)) { node.forEach(walk); return; }
+ if (!node || typeof node !== 'object') return;
+ const rec = node as Record;
+ const cond = typeof rec.if === 'string' ? rec.if : '';
+ const m = cond.match(/templateEmphasizeType === '(TEXT|IMAGE|ITEM_LIST)'/);
+ if (m && !found[m[1]]) found[m[1]] = cond;
+ Object.values(rec).forEach(walk);
+ };
+ walk(sectionsRoot);
+ return found;
+ };
+
+ it.each(['TEXT', 'IMAGE', 'ITEM_LIST'])('강조유형 %s 블록은 그 유형에서만 노출된다', (type) => {
+ const expr = conditionalBlocks()[type];
+ expect(expr, `${type} 조건부 블록이 섹션에 없다`).toBeTruthy();
+ for (const current of ['NONE', 'TEXT', 'IMAGE', 'ITEM_LIST']) {
+ expect(evalWithModal(expr, { content: { templateEmphasizeType: current } }), `${current} → ${type}`).toBe(current === type);
+ }
+ });
+
+ it('부가정보(templateExtra) 는 EX·MI 메시지 유형에서만 노출된다', () => {
+ expect(JSON.stringify(sectionsRoot)).toContain("_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'");
+ });
+
+ const REPEAT_CAPS: Array<[string, number]> = [['templateItem?.list', 10], ['buttons', 5], ['quickReplies', 10]];
+
+ it.each(REPEAT_CAPS)('반복 입력 %s 는 상한 %i 에 도달하면 추가가 잠긴다', (pathFragment, cap) => {
+ const addBtn = findAllByName(sectionsRoot, 'Button').find((b) => {
+ const d = String((b.props as { disabled?: string } | undefined)?.disabled ?? '');
+ return d.includes(pathFragment) && d.includes(`>= ${cap}`);
+ });
+ expect(addBtn, `${pathFragment} 상한 ${cap} 을 거는 추가 버튼이 없다`).toBeTruthy();
+ const expr = String((addBtn?.props as { disabled?: string }).disabled);
+ const key = pathFragment.includes('templateItem') ? 'templateItem' : pathFragment;
+ const makeContent = (n: number): Record => {
+ const arr = Array.from({ length: n }, () => ({}));
+ return key === 'templateItem' ? { templateItem: { list: arr } } : { [key]: arr };
+ };
+ expect(evalWithModal(expr, { content: makeContent(cap - 1) })).toBe(false);
+ expect(evalWithModal(expr, { content: makeContent(cap) })).toBe(true);
+ expect(evalWithModal(expr, { content: makeContent(cap + 1) })).toBe(true);
+ expect(evalWithModal(expr, { content: {} })).toBe(false);
+ });
+
+ it('버튼 linkType 별 조건부 링크 필드가 선택한 유형에서만 노출된다(부록 A-3 대응표 기준)', () => {
+ const EXPECTED_FOR_FIELD: Record = {
+ linkMo: ['WL'], linkPc: ['WL'], linkAnd: ['AL'], linkIos: ['AL'], telNumber: ['TN'], pluginId: ['P1', 'P2', 'P3'],
+ };
+ const ALL_LINK_TYPES = ['WL', 'AL', 'DS', 'BK', 'MD', 'AC', 'BC', 'BT', 'P1', 'P2', 'P3', 'TN', 'MP'];
+ const inputs = findAllByName(sectionsRoot, 'Input').filter((n) => /buttonsItem\.linkType === '/.test(String((n as { if?: string }).if ?? '')));
+ const seen = new Set();
+ for (const node of inputs) {
+ const value = String((node.props as { value?: string } | undefined)?.value ?? '');
+ const field = (value.match(/buttonsItem\.([A-Za-z]+)/) ?? [])[1] as string;
+ expect(EXPECTED_FOR_FIELD[field], `부록 A-3 에 없는 조건부 필드: ${field}`).toBeTruthy();
+ seen.add(field);
+ for (const lt of ALL_LINK_TYPES) {
+ expect(evalBinding(String((node as { if?: string }).if), { buttonsItem: { linkType: lt } }), `${field} @ ${lt}`).toBe(EXPECTED_FOR_FIELD[field].includes(lt));
+ }
+ }
+ expect([...seen].sort()).toEqual(Object.keys(EXPECTED_FOR_FIELD).sort());
+ });
+});
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..998b17a5
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings.test.tsx
@@ -0,0 +1,435 @@
+/**
+ * 비즈뿌리오 메시징 플러그인 환경설정 레이아웃 구조 검증 (§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', 'suppress',
+ 'sirsoft-message_bizppurio.uploadTemplateImage',
+ 'sirsoft-message_bizppurio.insertVariable',
+ ];
+ 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 — templates 탭 readiness 게이트 (#597 재편)', () => {
+ // 구 조회 전용 목록(alimtalk_templates + templateListError 배너)은 #597 에서
+ // DB 목록 관리 화면(bizppurio_templates_list)으로 교체됐다. 관리 화면 상세 배선은
+ // plugin_settings_manage.test.tsx 가 담당하고, 여기서는 탭 골격만 잠근다.
+ it('templates_readiness 데이터소스가 templates 탭에서만 조회된다', () => {
+ const sources = (root as { data_sources?: AnyNode[] }).data_sources ?? [];
+ const ds = sources.find((s) => s.id === 'templates_readiness') as
+ | Record
+ | undefined;
+ expect(ds).toBeTruthy();
+ expect(ds?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates-readiness');
+ expect(ds?.if).toContain("query.tab ?? 'connection') === 'templates'");
+ });
+
+ it('readiness 안내(!ready)와 관리 뷰(ready)가 상호배타로 존재한다', () => {
+ const notice = findById(layout, 'templates_readiness') as Record | null;
+ const manageView = findById(layout, 'templates_manage_view') as Record | null;
+ expect(notice).toBeTruthy();
+ expect(manageView).toBeTruthy();
+ expect(notice?.if).toBe('{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}');
+ expect(manageView?.if).toBe('{{templates_readiness?.data?.ready}}');
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx
new file mode 100644
index 00000000..3bf43fb3
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/plugin_settings_manage.test.tsx
@@ -0,0 +1,566 @@
+// e2e:allow 관리 화면 mergeQuery 왕복·SMS 모달 저장 왕복 브라우저 흐름은
+// tests/Playwright/specs/admin/template-lifecycle.spec.ts(bizppurio_manage_round_trip_e2e)가 담당.
+/**
+ * 플러그인 설정 — 알림 템플릿 관리 탭(DB 목록) 구조 검증 (#597 §4.3)
+ *
+ * @effects manage_screen_lists_db_rows_with_merge_query_round_trip, inspection_request_carries_reviewer_comment
+ *
+ * templates 탭은 DB 목록 관리 화면이다:
+ * - 데이터소스 bizppurio_templates_list(GET /admin/templates) — 필터·검색·페이지는
+ * URL 쿼리 SSoT(bz_status/bz_search/bz_page, mergeQuery 왕복 규약)
+ * - templates_manage_view — readiness 충족 시에만, 미충족이면 readiness 안내가 담당(배타)
+ * - 행 액션은 알림 설정 탭 행 하단과 동일 엔드포인트(request/cancel-request/cancel-approval/
+ * release/sync/delivery) + 관리 전용 삭제(modal_bizppurio_delete, DELETE)
+ */
+
+import { describe, it, expect } from 'vitest';
+import layout from '../../../layouts/admin/plugin_settings.json';
+import sections from '../../../extensions/notification_template_form_sections.json';
+import footerActions from '../../../extensions/notification_template_form_footer_actions.json';
+import ko from '../../../lang/ko.json';
+import en from '../../../lang/en.json';
+import { findById, findAllByName, evalBinding, type AnyNode } from './helpers';
+
+const root = layout as unknown as AnyNode;
+const manageView = findById(root, 'templates_manage_view') as AnyNode;
+const modalRoot = { children: (layout as { modals?: AnyNode[] }).modals ?? [] } as AnyNode;
+
+describe('manage — 목록 데이터소스(URL 쿼리 SSoT)', () => {
+ const ds = ((root as { data_sources?: Array> }).data_sources ?? [])
+ .find((d) => d.id === 'bizppurio_templates_list');
+
+ it('bizppurio_templates_list 가 GET /admin/templates 를 조회한다(auto_fetch:false)', () => {
+ expect(ds).toBeTruthy();
+ expect(ds?.endpoint).toBe('/api/plugins/sirsoft-message_bizppurio/admin/templates');
+ expect(ds?.method).toBe('GET');
+ expect(ds?.auto_fetch).toBe(false);
+ });
+
+ it('params 가 query.bz_status/bz_search/bz_page 바인딩이다(목록 상태의 SSoT 는 URL)', () => {
+ const params = ds?.params as Record;
+ expect(params.status).toBe("{{query.bz_status ?? ''}}");
+ expect(params.search).toBe("{{query.bz_search ?? ''}}");
+ expect(params.page).toBe('{{query.bz_page ?? 1}}');
+ expect(params.per_page).toBe(20);
+ });
+
+ it('templates 탭 진입 시 init_actions 가 목록을 조회한다(새로고침 복원)', () => {
+ const inits = (root as { init_actions?: AnyNode[] }).init_actions ?? [];
+ const refetch = inits.find(
+ (a) => a.handler === 'refetchDataSource'
+ && (a.params as { dataSourceId?: string })?.dataSourceId === 'bizppurio_templates_list',
+ );
+ expect(refetch).toBeTruthy();
+ expect((refetch as { if?: string }).if).toContain("query.tab ?? 'connection') === 'templates'");
+ });
+
+ it('tab_templates 탭 버튼이 navigate(mergeQuery) 후 목록을 refetch 한다', () => {
+ const raw = JSON.stringify(findById(root, 'tab_templates'));
+ expect(raw).toContain('"handler":"navigate"');
+ expect(raw).toContain('"tab":"templates"');
+ expect(raw).toContain('"mergeQuery":true');
+ expect(raw).toContain('bizppurio_templates_list');
+ });
+});
+
+describe('manage — readiness 게이트(배타 전환)', () => {
+ it('관리 뷰는 readiness 충족 시에만 노출된다', () => {
+ expect(manageView).toBeTruthy();
+ expect(manageView.if).toBe('{{templates_readiness?.data?.ready}}');
+ });
+
+ it('readiness 미충족 안내는 !ready 조건으로 관리 뷰와 상호배타다', () => {
+ const notice = findById(root, 'templates_readiness');
+ expect(notice).toBeTruthy();
+ expect((notice as { if?: string }).if).toBe('{{templates_readiness?.data && !(templates_readiness?.data?.ready)}}');
+ });
+});
+
+describe('manage — 필터·검색·페이지 (mergeQuery 왕복 규약)', () => {
+ it('상태 필터 Select: value=query.bz_status, 옵션은 template.status.* 어휘, 변경 시 bz_page 리셋 + refetch', () => {
+ const select = findAllByName(manageView, 'Select')
+ .find((s) => (s.props as { value?: string })?.value === "{{query.bz_status ?? ''}}");
+ expect(select).toBeTruthy();
+ const options = String((select?.props as { options?: string })?.options ?? '');
+ expect(options).toContain("template.status.' + s");
+ expect(options).toContain("'draft','requested','approved','rejected','stopped','blocked','dormant'");
+ const raw = JSON.stringify(select);
+ expect(raw).toContain('"mergeQuery":true');
+ expect(raw).toContain('"bz_status":"{{$event.target.value}}"');
+ expect(raw).toContain('"bz_page":""');
+ expect(raw).toContain('bizppurio_templates_list');
+ });
+
+ it('검색: 입력은 로컬 초안(bzSearchDraft), 실행 버튼이 bz_search 반영 + bz_page 리셋 + refetch', () => {
+ const input = findAllByName(manageView, 'Input')
+ .find((i) => String((i.props as { value?: string })?.value ?? '').includes('bzSearchDraft'));
+ expect(input).toBeTruthy();
+ const searchBtn = findAllByName(manageView, 'Button')
+ .find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_search');
+ const raw = JSON.stringify(searchBtn);
+ expect(raw).toContain('"mergeQuery":true');
+ expect(raw).toContain('"bz_search":"{{_local.bzSearchDraft ?? \'\'}}"');
+ expect(raw).toContain('"bz_page":""');
+ expect(raw).toContain('bizppurio_templates_list');
+ });
+
+ it('새로고침 버튼은 URL 을 건드리지 않고 목록만 refetch 한다(보던 목록 유지)', () => {
+ const refreshBtn = findAllByName(manageView, 'Button')
+ .find((b) => (b.props as Record)?.['aria-label'] === '$t:sirsoft-message_bizppurio.templates.list.refresh');
+ expect(refreshBtn).toBeTruthy();
+ const raw = JSON.stringify(refreshBtn);
+ expect(raw).toContain('bizppurio_templates_list');
+ expect(raw).not.toContain('"handler":"navigate"');
+ });
+
+ it('Pagination 이 last_page 를 사용하고 페이지 이동은 bz_page 를 mergeQuery 로 나른 뒤 refetch 한다', () => {
+ const pagination = findAllByName(manageView, 'Pagination')[0];
+ expect(pagination).toBeTruthy();
+ const props = pagination.props as { currentPage?: string; totalPages?: string; hasMorePages?: string };
+ expect(props.currentPage).toBe('{{bizppurio_templates_list?.data?.pagination?.current_page ?? 1}}');
+ // last_page 는 대용량 목록 계약상 미상(null)일 수 있어 1 로 채우지 않는다 (pagination.md)
+ expect(props.totalPages).toBe('{{bizppurio_templates_list?.data?.pagination?.last_page ?? null}}');
+ expect(props.hasMorePages).toBe('{{bizppurio_templates_list?.data?.pagination?.has_more_pages ?? false}}');
+ const raw = JSON.stringify(pagination);
+ expect(raw).toContain('"mergeQuery":true');
+ expect(raw).toContain('"bz_page":"{{$args[0]}}"');
+ expect(raw).toContain('bizppurio_templates_list');
+ });
+
+ it('총 건수와 빈 목록 안내가 존재한다', () => {
+ const raw = JSON.stringify(manageView);
+ expect(raw).toContain('manage.total_count');
+ expect(raw).toContain('bizppurio_templates_list?.data?.pagination?.total');
+ expect(raw).toContain('manage.empty');
+ });
+});
+
+/**
+ * @effects manage_row_actions_match_row_footer_visibility
+ */
+describe('manage — 행 액션(알림 설정 탭 행 하단과 동일 엔드포인트)', () => {
+ /** 노드 JSON 에서 템플릿 admin API 경로만 추출한다({{...}} 표현식 부분은 정규화). */
+ const collectEndpoints = (json: unknown): Set => {
+ const text = JSON.stringify(json);
+ const matches = text.match(/\/api\/plugins\/sirsoft-message_bizppurio\/admin\/templates[^"]*/g) ?? [];
+ return new Set(matches.map((m) => m.replace(/\{\{[^}]+\}\}/g, '{id}')));
+ };
+
+ it('상태 전이 4종(request/cancel-request/release/sync)이 목록 갱신과 함께 배선된다', () => {
+ const raw = JSON.stringify(manageView);
+ for (const suffix of ['/request', '/cancel-request', '/release', '/sync']) {
+ expect(raw, `${suffix} 누락`).toContain(
+ `/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}${suffix}`,
+ );
+ }
+ // 전이 후에는 map 이 아니라 이 화면의 목록을 갱신한다
+ expect(raw).toContain('bizppurio_templates_list');
+ });
+
+ /**
+ * 관리 화면(bzRow)과 코어 [편집] 모달 섹션(_global.bz_tpl_modal)의 노출 조건을 같은 상태
+ * 집합에 **실제로 태워** 대조한다(제품 결정 2026-08-23 — 행 하단 버튼 폐지, 섹션이 SSoT).
+ *
+ * 문자열 동일성 단언은 두 면의 식이 실제로 같은 결과를 내는지 증명하지 못한다 — 두 식을
+ * 각자의 컨텍스트로 평가해 상태별 결과가 일치하는지를 본다.
+ */
+ const MANAGE_ROWS: Record> = {
+ '행만 있고 내용 없음': { has_content: false, status: 'draft', template_code: null },
+ draft: { has_content: true, status: 'draft', template_code: null },
+ requested: { has_content: true, status: 'requested', template_code: 'g7_a_1' },
+ approved: { has_content: true, status: 'approved', template_code: 'g7_a_1' },
+ rejected: { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [{ content: '반려 사유' }] },
+ '반려(사유 없음)': { has_content: true, status: 'rejected', template_code: 'g7_a_1', inspection_detail: [] },
+ dormant: { has_content: true, status: 'dormant', template_code: 'g7_a_1' },
+ stopped: { has_content: true, status: 'stopped', template_code: 'g7_a_1' },
+ blocked: { has_content: true, status: 'blocked', template_code: 'g7_a_1' },
+ };
+
+ /** manage 화면 식을 bzRow 컨텍스트로 평가 */
+ const evalManage = (expr: string, bzRow: Record): boolean => {
+ const body = expr.trim().replace(/^{{/, '').replace(/}}$/, '');
+ // eslint-disable-next-line no-new-func
+ return Boolean(new Function('bzRow', `return (${body});`)(bzRow));
+ };
+
+ /** 편집 모달 섹션 식을 _global.bz_tpl_modal 컨텍스트로 평가(섹션은 상세 GET 으로 status/template_code 를 시딩한다) */
+ const evalSection = (expr: string, bzRow: Record): boolean => {
+ const body = expr.trim().replace(/^{{/, '').replace(/}}$/, '');
+ // eslint-disable-next-line no-new-func
+ // 섹션은 READY(자기 알림 유형으로 시딩 + 상세 GET 완료) 컨텍스트에서 평가한다
+ const fn = new Function('_global', 'extensionPointProps', `return (${body});`);
+ return Boolean(fn(
+ { bz_tpl_modal: { notification_type: 'welcome', loading: false, status: bzRow.status, template_code: bzRow.template_code ?? null }, bz_tpl_ui: { confirmCancelApproval: false } },
+ { definition: { type: 'welcome' }, channel: 'alimtalk' },
+ ));
+ };
+
+ const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode;
+ const byTextKey = (root: AnyNode, key: string) => findAllByName(root, 'Button')
+ .find((b) => b.text === `$t:sirsoft-message_bizppurio.${key}`);
+
+ /** 관리 화면 ↔ 편집 모달 섹션에서 같은 의미를 갖는 버튼 쌍 */
+ const PAIRS = [
+ 'template.row.btn_cancel_request',
+ 'template.row.btn_release',
+ 'template.row.btn_refresh',
+ 'template.row.btn_edit_approved',
+ ];
+
+ it.each(PAIRS)('%s 의 노출 조건이 관리 화면과 편집 모달 섹션에서 상태별로 같은 결과를 낸다', (key) => {
+ const m = byTextKey(manageView, key);
+ const f = byTextKey(sectionsRoot, key);
+ expect(m?.if, `관리 화면에 ${key} if 가 없다`).toBeTruthy();
+ expect(f?.if, `편집 모달 섹션에 ${key} if 가 없다`).toBeTruthy();
+
+ for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) {
+ expect(evalManage(m!.if as string, bzRow), `${key} @ ${state}`)
+ .toBe(evalSection(f!.if as string, bzRow));
+ }
+ });
+
+ it('관리 화면 [검수 신청]은 draft + 내용 보유에서만 노출된다(편집 모달은 [저장 후 검수 신청]이 본문 입력으로 판정)', () => {
+ const m = byTextKey(manageView, 'template.row.btn_request');
+ expect(m?.if).toBeTruthy();
+ for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) {
+ expect(evalManage(m!.if as string, bzRow), `검수 신청 @ ${state}`).toBe(bzRow.status === 'draft' && bzRow.has_content === true);
+ }
+ });
+
+ it('작성/수정 버튼은 편집 모달의 작성 폼이 열리는 상태(draft/rejected)에서만 노출된다', () => {
+ const m = findAllByName(manageView, 'Button')
+ .find((b) => typeof b.text === 'string' && b.text.includes("sirsoft-message_bizppurio.manage.' + (bzRow.has_content"));
+ expect(m?.if, '관리 화면 작성/수정 버튼을 찾지 못했다').toBeTruthy();
+
+ const editor = findById(sectionsRoot, 'bizppurio_tpl_editor');
+ expect(editor?.if).toBeTruthy();
+ for (const [state, bzRow] of Object.entries(MANAGE_ROWS)) {
+ expect(evalManage(m!.if as string, bzRow), `작성/수정 @ ${state}`).toBe(evalSection(editor!.if as string, bzRow));
+ }
+ });
+
+ it('작성/수정 버튼의 라벨이 내용 유무로 갈린다(미작성 행에 "수정" 이 뜨지 않는다)', () => {
+ const m = findAllByName(manageView, 'Button')
+ .find((b) => typeof b.text === 'string' && b.text.includes("sirsoft-message_bizppurio.manage.' + (bzRow.has_content"));
+ const text = m?.text as string;
+ expect(text).toContain('bzRow.has_content');
+ expect(text).toContain('btn_edit');
+ expect(text).toContain('btn_compose');
+ expect(ko.manage.btn_compose).toBeTruthy();
+ expect(en.manage.btn_compose).toBeTruthy();
+ });
+
+ it('상태 전이·delivery upsert 엔드포인트 계열이 편집 모달 섹션·푸터 액션과 같다', () => {
+ const layoutEndpoints = collectEndpoints(layout);
+ const sharedEndpoints = collectEndpoints([sections, footerActions]);
+ // 편집 모달이 쓰는 계열(map 조회·상세 GET 제외 — 관리 화면은 DB 목록을 쓴다)이 관리 화면에도 존재한다
+ for (const ep of sharedEndpoints) {
+ if (ep.endsWith('/map') || ep === '/api/plugins/sirsoft-message_bizppurio/admin/templates/{id}') continue;
+ expect(layoutEndpoints, `관리 화면에 ${ep} 누락`).toContain(ep);
+ }
+ expect(JSON.stringify(layout)).toContain(
+ '/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval',
+ );
+ expect(JSON.stringify(layout)).toContain(
+ '/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}',
+ );
+ });
+
+ it('모달 5종(작성/SMS/승인취소/반려/삭제)이 이 레이아웃에 등록된다', () => {
+ const ids = ((layout as { modals?: AnyNode[] }).modals ?? []).map((m) => m.id);
+ expect(ids).toEqual([
+ 'modal_bizppurio_template',
+ 'modal_bizppurio_sms',
+ 'modal_bizppurio_cancel_approval',
+ 'modal_bizppurio_rejection',
+ 'modal_bizppurio_delete',
+ ]);
+ });
+});
+
+describe('manage — 삭제 모달(modal_bizppurio_delete)', () => {
+ const modal = findById(modalRoot, 'modal_bizppurio_delete');
+
+ it('행 삭제 버튼이 bz_delete_modal seed 후 삭제 확인 모달을 연다', () => {
+ const deleteBtn = findAllByName(manageView, 'Button')
+ .find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_delete');
+ expect(deleteBtn).toBeTruthy();
+ const raw = JSON.stringify(deleteBtn);
+ expect(raw).toContain('"bz_delete_modal"');
+ expect(raw).toContain('modal_bizppurio_delete');
+ });
+
+ it('확정 시 DELETE /admin/templates/{id} 후 목록 갱신 + 모달 닫힘', () => {
+ expect(modal).toBeTruthy();
+ const raw = JSON.stringify(modal);
+ expect(raw).toContain('/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_delete_modal?.id}}');
+ expect(raw).toContain('"method":"DELETE"');
+ expect(raw).toContain('bizppurio_templates_list');
+ expect(raw).toContain('"closeModal"');
+ });
+
+ it('카카오측 동반 삭제 여부(kakao_deleted)에 따라 완료 문구를 분기한다', () => {
+ const raw = JSON.stringify(modal);
+ expect(raw).toContain('kakao_deleted');
+ expect(raw).toContain('manage.delete.done_with_kakao');
+ expect(raw).toContain('manage.delete.done_db_only');
+ });
+});
+
+describe('manage — i18n 정합(manage.* 키 가족)', () => {
+ it("동적 접두 'manage.owner.' 가족: core/module/plugin 라벨이 ko·en 에 존재한다", () => {
+ for (const dict of [ko, en] as Array>) {
+ for (const owner of ['core', 'module', 'plugin']) {
+ expect(dict.manage.owner[owner], `manage.owner.${owner}`).toBeTruthy();
+ }
+ }
+ });
+
+ it('목록 컬럼 헤더 키 7종이 ko·en 에 존재한다', () => {
+ const columns = ['notification', 'owner', 'status', 'sms', 'requested_at', 'synced_at', 'actions'];
+ for (const dict of [ko, en] as Array>) {
+ for (const col of columns) {
+ expect(dict.manage.columns[col], `manage.columns.${col}`).toBeTruthy();
+ }
+ }
+ });
+});
+
+/**
+ * @effects upload_in_progress_locks_save_buttons_not_cancel, sms_modal_edits_body_per_locale_tab
+ */
+describe('manage 모달 — 이미지 업로드 배선·SMS 언어 탭 (#597 §15.2 U1 / §15.1)', () => {
+ /**
+ * 관리 화면(plugin_settings.json)은 3면 오버레이(notification_tab_*.json)의 패리티 대상이
+ * **아니다** — 세 오버레이는 서로 전문 동일성으로 묶여 있지만 이 면은 별도 파일이다.
+ * 그래서 §15.2 U1(업로드 중 저장 잠금)·§15.1(SMS 언어 탭)이 이 면에서 되돌려져도
+ * 다른 어떤 테스트도 red 가 되지 않는다. 여기서 직접 고정한다.
+ */
+ const modalRaw = JSON.stringify(modalRoot);
+
+ it('작성 모달의 저장 계열 4버튼이 업로드 중 잠기고, 취소는 잠기지 않는다', () => {
+ const composeModal = findById(modalRoot, 'modal_bizppurio_template') as AnyNode;
+ expect(composeModal, '관리 화면에 작성 모달이 없다').toBeTruthy();
+
+ const buttons = findAllByName(composeModal, 'Button');
+ const saveButtons = buttons.filter((b) => /template\.form\.btn_save(_request)?$/
+ .test((b.text ?? '').replace('$t:sirsoft-message_bizppurio.', '')));
+ expect(saveButtons.length, '신규/수정 × 저장/저장후신청 = 4').toBe(4);
+
+ for (const b of saveButtons) {
+ const disabled = String((b.props as { disabled?: string } | undefined)?.disabled ?? '');
+ expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }),
+ '업로드 중 저장이 열려 있으면 빈 이미지 URL 로 저장된다').toBe(true);
+ expect(evalBinding(disabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: false } } }),
+ '업로드가 끝났는데 저장이 잠겨 있으면 저장할 방법이 없다').toBe(false);
+ }
+
+ const cancel = buttons.find((b) => b.text === '$t:common.cancel');
+ const cancelDisabled = String((cancel?.props as { disabled?: string } | undefined)?.disabled ?? '');
+ expect(evalBinding(cancelDisabled, { _global: { bz_tpl_modal: {}, bz_tpl_upload: { uploading: true } } }),
+ '취소까지 잠그면 업로드가 매달렸을 때 모달을 빠져나갈 수 없다').toBe(false);
+ });
+
+ it('업로드 실패 배너는 상태에서만 읽고 fallback 을 가진다', () => {
+ expect(modalRaw).toContain("_global.bz_tpl_upload?.error ?? ''");
+ });
+
+ it('SMS 모달이 로케일 탭을 제공하고 본문을 로케일 맵으로 읽고 쓴다', () => {
+ const tabs = findById(modalRoot, 'bz_sms_lang_tabs');
+ expect(tabs, '관리 화면 SMS 모달에 언어 탭이 없으면 ko 한 벌만 입력된다').toBeTruthy();
+ expect(JSON.stringify(tabs)).toContain('{{$locales}}');
+ expect(modalRaw).toContain('bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale]');
+ expect(modalRaw).toContain('"sms_body":"{{Object.assign({}, _global.bz_sms_modal?.body)}}"');
+ expect(modalRaw).toContain("'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)");
+ });
+});
+
+describe('manage 모달 — 검수자 전달 의견 (#597 §18.7, 제품 결정 2026-08-23)', () => {
+ const composeModal = findById(modalRoot, 'modal_bizppurio_template') as AnyNode;
+ const sectionsRoot = { children: (sections as { components?: AnyNode[] }).components ?? [] } as AnyNode;
+ const collectHandlers = (node: unknown, acc: AnyNode[] = []): AnyNode[] => {
+ if (Array.isArray(node)) node.forEach((c) => collectHandlers(c, acc));
+ else if (node && typeof node === 'object') {
+ const rec = node as AnyNode;
+ if (typeof rec.handler === 'string') acc.push(rec);
+ Object.values(rec).forEach((v) => collectHandlers(v, acc));
+ }
+ return acc;
+ };
+
+ it('작성 모달에 bz_manage_request_comment 입력란(≤500)이 있고 편집 모달 섹션과 같은 상태 키·바인딩을 쓴다', () => {
+ const block = findById(composeModal, 'bz_manage_request_comment') as AnyNode;
+ expect(block).toBeTruthy();
+ const ta = findAllByName(block, 'Textarea')[0] as AnyNode;
+ expect((ta.props as { name: string }).name).toBe('bz_request_comment');
+ expect((ta.props as { maxLength: number }).maxLength).toBe(500);
+ const sectionsTa = findAllByName(findById(sectionsRoot, 'bizppurio_tpl_request_comment') as AnyNode, 'Textarea')[0] as AnyNode;
+ expect((ta.props as { value: string }).value).toBe((sectionsTa.props as { value: string }).value);
+ expect(JSON.stringify(ta.actions)).toBe(JSON.stringify(sectionsTa.actions));
+ });
+
+ it('작성 모달의 request 2노드는 comment 를 싣고, 행 단위 [검수 신청](입력란 없음)은 싣지 않는다', () => {
+ const all = collectHandlers(root).filter((a) => a.handler === 'apiCall' && String(a.target).endsWith('/request'));
+ const modalReqs = all.filter((a) => !String(a.target).includes('{{bzRow.id}}'));
+ const rowReqs = all.filter((a) => String(a.target).includes('{{bzRow.id}}'));
+ expect(modalReqs).toHaveLength(2);
+ expect(rowReqs).toHaveLength(1);
+ for (const r of modalReqs) {
+ expect((r.params as { body: { comment: string } }).body.comment).toBe("{{_global.bz_tpl_modal?.request_comment ?? ''}}");
+ }
+ for (const r of rowReqs) expect((r.params as { body?: unknown }).body).toBeUndefined();
+ });
+
+ it('바로연결 블록(헤더+목록) 바로 뒤 형제로 놓이고, 라벨+[바로연결 추가] flex 행 안에 끼지 않는다 (회귀: 화면 검수 지적 2026-08-23)', () => {
+ const ancestorsOf = (root: AnyNode, id: string, anc: AnyNode[] = []): AnyNode[] | null => {
+ for (const c of (root.children as AnyNode[] | undefined) ?? []) {
+ if (c.id === id) return anc.concat(root);
+ const r = ancestorsOf(c, id, anc.concat(root));
+ if (r) return r;
+ }
+ return null;
+ };
+ const chain = ancestorsOf(composeModal, 'bz_manage_request_comment') as AnyNode[];
+ expect(chain).toBeTruthy();
+ const parent = chain[chain.length - 1];
+ const siblings = parent.children as AnyNode[];
+ const idx = siblings.findIndex((c) => c.id === 'bz_manage_request_comment');
+ const prev = siblings[idx - 1];
+ expect(JSON.stringify(prev)).toContain('template.form.quick_reply_add');
+ expect(JSON.stringify(prev)).toContain('quickRepliesItem');
+ for (const a of chain) {
+ expect(String((a.props as { className?: string } | undefined)?.className ?? ''), `ancestor ${a.id ?? a.name}`).not.toMatch(/\bflex\b/);
+ }
+ });
+
+ it('행에서 작성 모달을 열 때 request_comment 를 빈 문자열로 시딩한다(다른 행의 의견 이월 금지)', () => {
+ const seeds = collectHandlers(root)
+ .filter((a) => a.handler === 'setState' && (a.params as Record)?.bz_tpl_modal);
+ expect(seeds.length).toBeGreaterThan(0);
+ for (const s of seeds) {
+ expect(((s.params as Record).bz_tpl_modal as Record).request_comment).toBe('');
+ }
+ });
+
+ it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => {
+ const pick = (json: unknown, key: string): unknown => key.split('.')
+ .reduce((acc, seg) => (acc as Record)?.[seg], json);
+ for (const key of ['request_comment', 'request_comment_placeholder', 'request_comment_hint']) {
+ expect(pick(ko, `template.form.${key}`), `ko ${key}`).toBeTruthy();
+ expect(pick(en, `template.form.${key}`), `en ${key}`).toBeTruthy();
+ }
+ });
+});
+
+describe('manage — 목록 SMS 열은 상태 배지 (행 하단과 같은 규칙, 제품 결정 2026-08-23)', () => {
+ // iteration(bzRow) 안이라 정적 id 를 둘 수 없다 — 텍스트 식으로 찾는다
+ const badge = findAllByName(manageView, 'Span').find((n) => String(n.text).includes("manage.sms_fallback') : $t('sirsoft-message_bizppurio.template.row.off')")) as AnyNode;
+ const evalCell = (expr: string, row: Record): unknown => {
+ const body = String(expr).trim().replace(/^\{\{/, '').replace(/\}\}$/, '');
+ // eslint-disable-next-line no-new-func
+ return new Function('$t', 'bzRow', `return (${body});`)((k: string) => k, row);
+ };
+
+ it('단독/대체는 초록, 미사용은 회색 배지이며 평문 "-" 는 남지 않는다', () => {
+ expect(badge).toBeTruthy();
+ const cls = String((badge.props as { className: string }).className);
+ expect(String(evalCell(cls, { sms_only: true }))).toContain('bg-green-100');
+ expect(String(evalCell(cls, { fallback_sms_enabled: true }))).toContain('bg-green-100');
+ expect(String(evalCell(cls, {}))).toContain('bg-gray-100');
+ expect(String(evalCell(cls, {}))).toContain('rounded');
+ expect(evalCell(String(badge.text), { sms_only: true })).toBe('sirsoft-message_bizppurio.manage.sms_only');
+ expect(evalCell(String(badge.text), { fallback_sms_enabled: true })).toBe('sirsoft-message_bizppurio.manage.sms_fallback');
+ expect(evalCell(String(badge.text), {})).toBe('sirsoft-message_bizppurio.template.row.off');
+ expect(JSON.stringify(manageView)).not.toContain("sms_fallback') : '-')");
+ });
+});
+
+describe('manage — 목록 모바일 카드 전환 + 도구줄 버튼 일관성 (제품 결정 2026-08-24)', () => {
+ const header = findById(manageView, 'bz_manage_list_header') as AnyNode;
+ const rowsWrap = findById(manageView, 'bz_manage_list_rows') as AnyNode;
+ const findIterationRow = (n: AnyNode | null): AnyNode | null => {
+ if (!n) return null;
+ if (n.iteration?.item_var === 'bzRow') return n;
+ for (const c of n.children ?? []) {
+ const found = findIterationRow(c);
+ if (found) return found;
+ }
+ return null;
+ };
+ const portableClass = (n: AnyNode | null | undefined): string => String(
+ ((n?.responsive as Record | undefined)?.portable?.props?.className) ?? '',
+ );
+
+ it('Table 마크업이 사라지고 grid 목록으로 렌더된다(헤더 7컬럼 키 유지, portable 에서 숨김)', () => {
+ expect(findAllByName(manageView, 'Table').length).toBe(0);
+ expect(header).toBeTruthy();
+ expect(String((header.props as { className?: string })?.className)).toContain('grid-cols-12');
+ expect(portableClass(header)).toBe('hidden');
+ const raw = JSON.stringify(header);
+ for (const c of ['notification', 'owner', 'status', 'sms', 'requested_at', 'synced_at', 'actions']) {
+ expect(raw, `columns.${c}`).toContain('manage.columns.' + c);
+ }
+ });
+
+ it('행은 데스크톱 grid, portable 에서 카드(flex-wrap)로 전환된다 — responsive 는 반복 노드가 아니라 내부 래퍼에 둔다', () => {
+ const row = findIterationRow(rowsWrap);
+ expect(row).toBeTruthy();
+ expect(row!.responsive, '반복 노드 자체에 responsive 를 두지 않는다(내부 래퍼 담당)').toBeUndefined();
+ const inner = (row!.children ?? [])[0] as AnyNode;
+ expect(String((inner.props as { className?: string })?.className)).toContain('grid grid-cols-12');
+ expect(portableClass(inner)).toContain('flex flex-wrap');
+ });
+
+ it('소속·신청일·동기화일 셀은 portable 에서 숨고, 카드 메타 줄이 대신 노출된다', () => {
+ const row = findIterationRow(rowsWrap) as AnyNode;
+ const rowInner = (row.children ?? [])[0] as AnyNode;
+ const hiddenCells = (rowInner.children ?? []).filter((c) => portableClass(c) === 'hidden');
+ expect(hiddenCells.length).toBeGreaterThanOrEqual(3);
+ // 메타 줄: 신청일+동기화일을 한 텍스트에 담고, 데스크톱(hidden)→portable 에서만 보인다
+ const all: AnyNode[] = [];
+ const walk = (n: AnyNode) => { all.push(n); (n.children ?? []).forEach(walk); };
+ walk(row);
+ const meta = all.find((n) => typeof n.text === 'string'
+ && n.text.includes("requested_at ?? '').slice(0, 10)")
+ && n.text.includes('last_synced_at'));
+ expect(meta, '카드 메타 줄(신청·동기화 합본)이 없다').toBeTruthy();
+ expect(String((meta!.props as { className?: string })?.className)).toContain('hidden');
+ expect(portableClass(meta)).not.toContain('hidden');
+ expect(portableClass(meta).length).toBeGreaterThan(0);
+ });
+
+ it('행 액션 묶음은 카드 전환 후에도 단일 인스턴스다(삭제 버튼 1개)', () => {
+ const delBtns = findAllByName(manageView, 'Button')
+ .filter((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_delete');
+ expect(delBtns.length).toBe(1);
+ });
+
+ it('검색 버튼이 btn btn-primary 계열로 통일된다(구 미니 버튼 조합 제거)', () => {
+ const searchBtn = findAllByName(manageView, 'Button')
+ .find((b) => b.text === '$t:sirsoft-message_bizppurio.manage.btn_search') as AnyNode;
+ expect(searchBtn).toBeTruthy();
+ const cls = String((searchBtn.props as { className?: string })?.className);
+ expect(cls).toContain('btn btn-primary');
+ expect(cls).not.toContain('text-xs');
+ });
+
+ it('새로고침이 btn-icon 아이콘 버튼으로 통일된다(fa-arrows-rotate + aria-label, refetch 전용 유지)', () => {
+ const refreshBtn = findAllByName(manageView, 'Button')
+ .find((b) => (b.props as Record)?.['aria-label'] === '$t:sirsoft-message_bizppurio.templates.list.refresh') as AnyNode;
+ expect(refreshBtn).toBeTruthy();
+ expect(String((refreshBtn.props as { className?: string })?.className)).toContain('btn-icon');
+ const raw = JSON.stringify(refreshBtn);
+ expect(raw).toContain('fa-arrows-rotate');
+ expect(raw).toContain('bizppurio_templates_list');
+ expect(raw).not.toContain('"handler":"navigate"');
+ });
+
+ it('검색 입력·상태 필터가 portable 에서 전폭 계열로 전환된다', () => {
+ const input = findAllByName(manageView, 'Input')
+ .find((i) => String((i.props as { value?: string })?.value ?? '').includes('bzSearchDraft')) as AnyNode;
+ expect(portableClass(input)).toContain('w-full');
+ const select = findAllByName(manageView, 'Select')
+ .find((sel) => (sel.props as { value?: string })?.value === "{{query.bz_status ?? ''}}") as AnyNode;
+ expect(portableClass(select)).toContain('flex-1');
+ });
+});
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts
new file mode 100644
index 00000000..e6e5f48e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/__tests__/layouts/template_request_reentrancy_guard.test.ts
@@ -0,0 +1,203 @@
+/**
+ * @file template_request_reentrancy_guard.test.ts
+ * @description 검수 신청 체인 재진입(더블 클릭) 가드 회귀 테스트 (#597 §6.3 10c)
+ *
+ * 배경: 검수 신청(POST …/request)은 kapi add/update 를 동반하므로 더블 클릭이
+ * 두 체인을 발화하면 카카오측 중복 등록·중복 채번이 발생할 수 있다. disabled
+ * prop 은 React 리렌더 이후에만 반영되어 같은 태스크에서 연속 디스패치되는
+ * 두 번째 click 을 막지 못한다 — 실측: 더블 클릭 → PUT 2회 + request 2회.
+ *
+ * 방어는 click 액션(sequence) 레벨의 `if` 재진입 가드다(코어 선례:
+ * admin_role_form.json 등의 `"if": "{{!_global.isSaving}}"`). 엔진 if 평가는
+ * 디스패치 시점의 동기 검사라 리렌더를 기다리지 않는다.
+ *
+ * 이 테스트는 신청 체인을 포함하는 모든 click 액션이 재진입 가드(if)를
+ * 보유하는지 산출 파일 전수(편집 모달 푸터 1본 + 관리 화면)로 고정한다. 가드가 한 파일에서라도 빠지면
+ * 그 면에서만 중복 신청이 재발한다.
+ *
+ * 실행 검증은 §6.3 10c 브라우저 실측이 담당한다. 라운드 5 실측 결과를 여기 남긴다:
+ * **클라이언트 if 가드만으로는 요청 2회가 나간다.** setState 가 전역 스토어에 반영되기
+ * 전에 두 번째 click 이 디스패치되기 때문이며, 30ms 간격 더블 클릭에서 재현된다.
+ *
+ * 중복 신청을 실제로 막는 것은 **서버의 원자 선점**(claimForInspection)이다 — 실측에서
+ * 두 번째 POST …/request 는 422 "현재 상태(requested)에서는 검수를 신청할 수 없습니다."
+ * 로 거부됐고, 카카오측 중복 등록은 발생하지 않았다. 저장(PUT)은 멱등이라 무해하다.
+ *
+ * 따라서 이 구조 테스트는 "가드가 선언돼 있다" 만 고정한다 — 효력의 SSoT 는 서버 가드이고,
+ * 그것은 PHPUnit(claimForInspection)과 §6.3 10c 실측이 고정한다.
+ *
+ * @effects request_chain_click_actions_carry_reentrancy_guard, save_chain_click_actions_carry_reentrancy_guard
+ */
+
+import fs from 'fs';
+import path from 'path';
+import { describe, it, expect } from 'vitest';
+
+const BASE = path.resolve(__dirname, '../../../..');
+
+const FILES = [
+ // 3면 공유 1본 — 코어 [편집] 모달 푸터에 주입되는 통합 저장/검수 신청 버튼(제품 결정 2026-08-23)
+ 'resources/extensions/notification_template_form_footer_actions.json',
+ // 플러그인 관리 화면의 자기 모달
+ 'resources/layouts/admin/plugin_settings.json',
+];
+
+interface ClickAction {
+ file: string;
+ targets: string[];
+ ifExpr: string | undefined;
+ raw: string;
+ buttonDisabled: string | undefined;
+}
+
+/**
+ * 검수 신청(…/request) apiCall 을 포함하는 click 액션을 전수 수집합니다.
+ * cancel-request 는 서버 상태 가드(422)로 순차 중복이 무해하므로 제외한다.
+ *
+ * @param file 저장소 루트 기준 상대 경로
+ * @returns 신청 체인을 품은 click 액션 목록
+ */
+function collectRequestClickActions(file: string): ClickAction[] {
+ const doc = JSON.parse(fs.readFileSync(path.join(BASE, file), 'utf-8'));
+ const found: ClickAction[] = [];
+
+ const collectTargets = (node: unknown, acc: string[]): void => {
+ if (!node || typeof node !== 'object') return;
+ if (Array.isArray(node)) {
+ node.forEach((c) => collectTargets(c, acc));
+ return;
+ }
+ const n = node as Record;
+ if (
+ n.handler === 'apiCall' &&
+ typeof n.target === 'string' &&
+ /\/request$/.test(n.target)
+ ) {
+ acc.push(n.target);
+ }
+ Object.values(n).forEach((v) => collectTargets(v, acc));
+ };
+
+ const walk = (node: unknown): void => {
+ if (!node || typeof node !== 'object') return;
+ if (Array.isArray(node)) {
+ node.forEach(walk);
+ return;
+ }
+ const n = node as Record;
+ if (Array.isArray(n.actions)) {
+ for (const action of n.actions) {
+ if (action?.type !== 'click') continue;
+ const targets: string[] = [];
+ collectTargets(action, targets);
+ if (targets.length > 0) {
+ found.push({
+ file,
+ targets,
+ ifExpr: action.if,
+ raw: JSON.stringify(action),
+ buttonDisabled: n.name === 'Button' ? n.props?.disabled : undefined,
+ });
+ }
+ }
+ }
+ Object.values(n).forEach(walk);
+ };
+
+ walk(doc);
+ return found;
+}
+
+describe('검수 신청 체인 재진입 가드 (#597 10c)', () => {
+ const all = FILES.flatMap(collectRequestClickActions);
+
+ it('신청 체인을 포함한 click 액션이 산출 파일 전체에서 수집된다 (0건이면 아래 단언은 공회전한다)', () => {
+ // 편집 모달 푸터 [저장 후 검수 신청] 1 + 관리 화면(모달 저장 후 신청 신규/수정 + 행 신청)
+ expect(all.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it.each(FILES)('%s 의 신청 체인 click 액션은 모두 재진입 if 가드를 갖는다', (file) => {
+ const actions = all.filter((a) => a.file === file);
+ for (const action of actions) {
+ expect(
+ action.ifExpr,
+ `${file} 의 신청 체인(${action.targets[0]})에 if 재진입 가드가 없다 — 더블 클릭이 중복 신청을 발화한다`,
+ ).toBeTruthy();
+ expect(
+ action.ifExpr,
+ `${file} 의 신청 체인 if 가드는 in-flight 플래그(isSaving/bz_row_busy)를 부정 조건으로 검사해야 한다`,
+ ).toMatch(/!\((_global\.bz_tpl_modal\?\.isSaving|_global\.bz_row_busy) \?\? false\)/);
+ }
+ });
+
+ it('신청 체인 버튼은 in-flight 플래그를 disabled 로 바인딩한다 (구독 없으면 액션 컨텍스트가 갱신되지 않아 if 가드가 항상 stale false 를 본다 — 실측)', () => {
+ for (const action of all) {
+ const flag = action.ifExpr?.includes('bz_row_busy') ? 'bz_row_busy' : 'bz_tpl_modal?.isSaving';
+ expect(
+ action.buttonDisabled,
+ `${action.file} 의 신청 체인 버튼(${action.targets[0]})은 disabled 를 ${flag} 로 바인딩해야 한다 — 바인딩이 없으면 리렌더·구독이 일어나지 않아 더블 클릭이 그대로 통과한다`,
+ ).toContain(flag);
+ }
+ });
+
+ it('bz_row_busy 플래그를 쓰는 체인은 성공·실패 양쪽에서 플래그를 해제한다', () => {
+ const rowChains = all.filter((a) => a.ifExpr?.includes('bz_row_busy'));
+ for (const action of rowChains) {
+ const resets = action.raw.match(/"bz_row_busy": ?false/g) ?? [];
+ expect(
+ resets.length,
+ `${action.file} 의 row 신청 체인은 onSuccess·onError 양쪽에서 bz_row_busy 를 해제해야 한다 — 한쪽만 해제하면 실패 후 버튼이 영구 잠긴다`,
+ ).toBeGreaterThanOrEqual(2);
+ }
+ });
+});
+
+describe('저장 체인 재진입 가드 (#597 라운드 5 §6.3 10c 실측 파생)', () => {
+ /**
+ * 브라우저 실측에서 [저장] 더블 클릭 시 PUT 이 **2회** 나갔다.
+ *
+ * 형제 버튼인 [저장 후 검수 신청] 은 이미 액션 레벨 if 로 재진입을 막고 있었는데
+ * 저장만 빠져 있었다 — 같은 모달의 같은 결함군에 규칙이 두 벌이었던 셈이다.
+ * disabled prop 은 리렌더 이후에만 반영되므로 같은 태스크의 두 번째 click 을 막지 못한다.
+ */
+ const GUARD = '{{!(_global.bz_tpl_modal?.isSaving ?? false)}}';
+
+ /** 저장 버튼(btn_save)의 click 액션을 전수 수집한다. */
+ const collectSaveClickActions = (file: string): Array<{ file: string; ifExpr?: string }> => {
+ const doc = JSON.parse(fs.readFileSync(path.join(BASE, file), 'utf-8'));
+ const found: Array<{ file: string; ifExpr?: string }> = [];
+
+ const walk = (node: unknown): void => {
+ if (!node || typeof node !== 'object') return;
+ if (Array.isArray(node)) { node.forEach(walk); return; }
+ const n = node as Record;
+ // 관리 화면 모달: text=form.btn_save / 편집 모달 푸터(통합 저장): 자식 Span 이 $t:common.save
+ const isSave = n.name === 'Button'
+ && ((typeof n.text === 'string' && n.text.endsWith('sirsoft-message_bizppurio.template.form.btn_save'))
+ || (Array.isArray(n.children) && JSON.stringify(n.children).includes('$t:common.save')
+ && JSON.stringify(n.props ?? {}).includes('bz_tpl_modal')));
+ if (isSave && Array.isArray(n.actions)) {
+ for (const a of n.actions) {
+ if (a?.type === 'click') found.push({ file, ifExpr: a.if });
+ }
+ }
+ Object.values(n).forEach(walk);
+ };
+
+ walk(doc);
+
+ return found;
+ };
+
+ it('저장 버튼의 click 액션이 산출 파일 전수에서 재진입 가드를 보유한다', () => {
+ const all = FILES.flatMap(collectSaveClickActions);
+ // 편집 모달 푸터 통합 [저장] 1 + 관리 화면 모달 (신규/수정) 2버튼
+ expect(all.length, '저장 버튼 click 액션 수').toBe(3);
+
+ for (const a of all) {
+ // 편집 모달 푸터의 통합 [저장]은 isSaving 가드에 상세 GET 완료 전 가드(loading)를 AND 로 더 단다 — 포함 판정
+ expect(a.ifExpr, `${a.file}: 저장 체인에 재진입 가드(if)가 없다 — 더블 클릭 시 중복 저장`)
+ .toContain(GUARD.slice(2, -2));
+ }
+ });
+});
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..134e09c8
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/index.ts
@@ -0,0 +1,17 @@
+/**
+ * sirsoft-message_bizppurio 플러그인 커스텀 핸들러 맵.
+ *
+ * 키는 네임스페이스 없는 핸들러 이름이며, ActionDispatcher 등록 시 플러그인
+ * 식별자가 네임스페이스로 접두된다. 예: insertVariable → sirsoft-message_bizppurio.insertVariable
+ *
+ * - insertVariable: 작성 모달·SMS 모달 본문에 `#{변수}` 커서 삽입 (#597 §4.2)
+ * - uploadTemplateImage: 이미지형 템플릿 이미지 업로드 프록시 호출 (multipart — apiCall 미적합)
+ */
+
+import { insertVariableHandler } from './insertVariable';
+import { uploadTemplateImageHandler } from './uploadTemplateImage';
+
+export const handlerMap = {
+ insertVariable: insertVariableHandler,
+ uploadTemplateImage: uploadTemplateImageHandler,
+} as const;
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts
new file mode 100644
index 00000000..4938e77e
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/insertVariable.ts
@@ -0,0 +1,117 @@
+/**
+ * insertVariable 핸들러 (#597 §4.2)
+ *
+ * 알림톡 템플릿 작성 모달·SMS 본문 모달에서 "사용 가능 변수" 칩을 클릭하면, 대상 본문의
+ * 현재 커서 위치에 `#{변수}` 를 삽입한다. 값 자체는 G7 상태를 SSoT 로 삼아 갱신한다
+ * (DOM 값을 직접 쓰지 않으므로 다음 렌더에서 상태값으로 덮여 사라지는 충돌이 없다).
+ *
+ * 커서 위치만 DOM 에서 읽고(selectionStart), 없으면 문자열 끝에 붙인다.
+ *
+ * 레이아웃 JSON 사용 예:
+ * {
+ * "handler": "sirsoft-message_bizppurio.insertVariable",
+ * "params": {
+ * "variable": "name",
+ * "target": "bz_template_content",
+ * "stateTarget": "global",
+ * "statePath": "bz_tpl_modal.content.templateContent"
+ * }
+ * }
+ */
+
+import type { ActionContext, ActionWithParams } from '../types';
+
+const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:InsertVariable')) ?? {
+ log: (...args: unknown[]) => console.log('[MessageBizppurio:InsertVariable]', ...args),
+ warn: (...args: unknown[]) => console.warn('[MessageBizppurio:InsertVariable]', ...args),
+ error: (...args: unknown[]) => console.error('[MessageBizppurio:InsertVariable]', ...args),
+};
+
+/** 삽입 대상 본문 필드(name) 기본값 */
+const DEFAULT_TARGET = 'bz_template_content';
+
+/** 상태 경로 기본값 */
+const DEFAULT_STATE_PATH = 'bz_tpl_modal.content.templateContent';
+
+/**
+ * 대상 본문의 커서 위치에 `#{변수}` 를 삽입하고 G7 상태를 갱신합니다.
+ *
+ * @param action 액션 객체(params.variable/target/stateTarget/statePath)
+ * @param context 액션 컨텍스트(state 스냅샷)
+ */
+export function insertVariableHandler(
+ action: ActionWithParams,
+ context: ActionContext,
+): void {
+ const G7Core = (window as any).G7Core;
+ const params = action.params ?? {};
+ const variable = String(params.variable ?? '').trim();
+ const target = String(params.target ?? DEFAULT_TARGET);
+ const stateTarget = String(params.stateTarget ?? 'global');
+ const statePath = String(params.statePath ?? DEFAULT_STATE_PATH);
+
+ if (variable === '') {
+ logger.warn('삽입할 변수명이 지정되지 않았습니다.');
+ return;
+ }
+
+ const setState = stateTarget === 'local'
+ ? G7Core?.state?.setLocal
+ : (G7Core?.state?.setGlobal ?? G7Core?.state?.set);
+ const getState = stateTarget === 'local'
+ ? G7Core?.state?.getLocal
+ : (G7Core?.state?.getGlobal ?? G7Core?.state?.get);
+
+ if (!setState || !getState) {
+ logger.error('G7Core.state API 를 사용할 수 없습니다.');
+ return;
+ }
+
+ // 현재 값: 핸들러 컨텍스트 스냅샷 우선(React 커밋 직후 stale globalState 회피), 없으면 getState.
+ const stateSnapshot = stateTarget === 'local'
+ ? ((context as any)?.state ?? getState() ?? {})
+ : (getState() ?? {});
+ const current = String(readPath(stateSnapshot, statePath) ?? '');
+
+ const token = `#{${variable}}`;
+
+ // 커서 위치: DOM 에서 selectionStart 를 읽되, 값 자체는 상태 기준으로 조립.
+ const field = document.querySelector(
+ `textarea[name="${target}"], input[name="${target}"]`,
+ );
+ const domValue = field?.value ?? '';
+ // DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.
+ const inSync = domValue === current;
+ const start = inSync ? (field?.selectionStart ?? current.length) : current.length;
+ const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;
+
+ const next = current.slice(0, start) + token + current.slice(end);
+
+ setState({ [statePath]: next });
+
+ // 커서를 삽입한 토큰 뒤로 이동(다음 렌더 후 DOM 반영되므로 requestAnimationFrame 안에서).
+ if (field) {
+ const caret = start + token.length;
+ requestAnimationFrame(() => {
+ try {
+ field.focus();
+ field.setSelectionRange(caret, caret);
+ } catch {
+ // setSelectionRange 미지원 input — 무시
+ }
+ });
+ }
+
+ logger.log(`변수 삽입: ${token} → ${stateTarget}:${statePath}`);
+}
+
+/**
+ * dot notation 경로로 객체에서 값을 읽습니다. (예: "bz_tpl_modal.content.templateContent")
+ *
+ * @param obj 대상 객체
+ * @param path dot 경로
+ * @return 경로 값(없으면 undefined)
+ */
+function readPath(obj: Record, path: string): unknown {
+ return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts
new file mode 100644
index 00000000..d5d6203f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/js/handlers/uploadTemplateImage.ts
@@ -0,0 +1,292 @@
+/**
+ * uploadTemplateImage 핸들러 (#597 §3.2 · 부록 A-7)
+ *
+ * 이미지형 알림톡 템플릿 작성 모달에서 파일 선택(change) 시, 선택한 이미지를 플러그인의
+ * 이미지 업로드 프록시(`POST /admin/templates/image` — kapi 위임)로 전송하고, 반환된
+ * 카카오 이미지 URL 을 params.statePathUrl 에, 파일명을 params.statePathName 에 설정한다.
+ *
+ * 업로드는 multipart/form-data 이므로 apiCall 핸들러로는 처리하기 번거로워 전용 핸들러로 둔다.
+ * 진행 상태·오류는 params.statePathStatus(기본 bz_tpl_upload) 하위의
+ * `{uploading, error}` 로 노출한다.
+ *
+ * 레이아웃 JSON 사용 예:
+ * {
+ * "type": "change",
+ * "handler": "sirsoft-message_bizppurio.uploadTemplateImage",
+ * "params": {
+ * "stateTarget": "global",
+ * "statePathUrl": "bz_tpl_modal.content.templateImageUrl",
+ * "statePathName": "bz_tpl_modal.content.templateImageName",
+ * "statePathStatus": "bz_tpl_upload"
+ * }
+ * }
+ */
+
+import type { ActionContext, ActionWithParams } from '../types';
+
+const logger = ((window as any).G7Core?.createLogger?.('MessageBizppurio:UploadImage')) ?? {
+ log: (...args: unknown[]) => console.log('[MessageBizppurio:UploadImage]', ...args),
+ warn: (...args: unknown[]) => console.warn('[MessageBizppurio:UploadImage]', ...args),
+ error: (...args: unknown[]) => console.error('[MessageBizppurio:UploadImage]', ...args),
+};
+
+const UPLOAD_URL = '/api/plugins/sirsoft-message_bizppurio/admin/templates/image';
+
+/** 서버 사유가 없을 때 쓰는 최후 폴백 문구의 다국어 키 */
+const FAILURE_KEY = 'sirsoft-message_bizppurio.template.form.image_upload_failed';
+
+/**
+ * 운영자 로케일을 Accept-Language 로 실어 보냅니다.
+ *
+ * 이 핸들러는 multipart 때문에 코어 ApiClient/ActionDispatcher 를 우회해 raw fetch 를
+ * 쓰므로, 두 경로가 공통으로 붙이는 로케일 헤더를 여기서 직접 재현한다. 빠뜨리면 서버
+ * 검증 메시지와 카카오 사유 원문이 운영자 언어가 아닌 앱 기본 로케일로 돌아온다.
+ *
+ * @returns Accept-Language 헤더 객체 (미설정 시 빈 객체)
+ */
+function localeHeader(): Record {
+ if (typeof window === 'undefined') {
+ return {};
+ }
+
+ const locale = localStorage.getItem('g7_locale');
+
+ return locale ? { 'Accept-Language': locale } : {};
+}
+
+/**
+ * 업로드 실패 폴백 문구를 반환합니다.
+ *
+ * @param G7Core 전역 G7Core 객체
+ * @returns 번역된 문구 (해석 실패 시 영문 폴백)
+ */
+function failureText(G7Core: any): string {
+ const translated = G7Core?.t?.(FAILURE_KEY);
+
+ return (typeof translated === 'string' && translated !== FAILURE_KEY)
+ ? translated
+ : 'Failed to upload the image.';
+}
+
+/**
+ * stateTarget(global|local)에 맞는 상태 setter 를 반환합니다.
+ *
+ * @param G7Core 전역 G7Core 객체
+ * @param target 'global' | 'local'
+ * @returns 상태 setter 또는 null
+ */
+function resolveSetter(G7Core: any, target: string): ((updates: Record) => void) | null {
+ if (target === 'local') {
+ return G7Core?.state?.setLocal ?? null;
+ }
+
+ return G7Core?.state?.setGlobal ?? G7Core?.state?.set ?? null;
+}
+
+/**
+ * stateTarget(global|local)에 맞는 상태 getter 를 반환합니다.
+ *
+ * @param G7Core 전역 G7Core 객체
+ * @param target 'global' | 'local'
+ * @returns 상태 getter 또는 null
+ */
+function resolveGetter(G7Core: any, target: string): (() => Record) | null {
+ if (target === 'local') {
+ return G7Core?.state?.getLocal ?? null;
+ }
+
+ return G7Core?.state?.getGlobal ?? G7Core?.state?.get ?? null;
+}
+
+/**
+ * dot notation 경로로 객체에서 값을 읽습니다.
+ *
+ * @param obj 대상 객체
+ * @param path dot 경로
+ * @returns 경로 값(없으면 undefined)
+ */
+function readPath(obj: Record, path: string): unknown {
+ return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
+}
+
+/**
+ * 이 업로드의 결과를 아직 써도 되는지 판정합니다.
+ *
+ * 응답이 도착했을 때 모달이 이미 다른 알림으로 바뀌었거나 다시 열렸다면, 그 결과는 지금
+ * 화면이 편집 중인 템플릿의 것이 아니다. 그대로 쓰면 A 알림의 이미지가 B 알림 폼에 기입되고
+ * (실패 경로에서는 B 가 방금 시딩한 값이 지워진다) 운영자에게는 아무 단서도 남지 않는다.
+ *
+ * 판정 신호는 진행 플래그 자신이다 — 모달을 여는 지점은 모두 statePathStatus 를
+ * `{uploading:false, error:null}` 로 리시드하므로, 우리가 켜 둔 uploading 이 사라졌다면
+ * 그 사이에 모달이 다시 시딩된 것이다.
+ *
+ * @param G7Core 전역 G7Core 객체
+ * @param stateTarget 'global' | 'local'
+ * @param pathStatus 업로드 상태 경로
+ * @returns 결과를 반영해도 되면 true
+ */
+function stillOwnsUpload(G7Core: any, stateTarget: string, pathStatus: string): boolean {
+ const getter = resolveGetter(G7Core, stateTarget);
+
+ // getter 를 못 쓰면 판정 불가 — 기존 동작(그대로 반영)을 유지한다.
+ if (!getter) {
+ return true;
+ }
+
+ try {
+ return readPath(getter() ?? {}, `${pathStatus}.uploading`) === true;
+ } catch {
+ return true;
+ }
+}
+
+/** 동시 업로드 차단 플래그 — 진행 중에는 새 파일 선택을 받지 않는다. */
+let uploadInFlight = false;
+
+/**
+ * 선택한 이미지 파일을 업로드하고 결과 URL 을 상태에 반영합니다.
+ *
+ * @param action 액션 객체(params.stateTarget/statePathUrl/statePathName/statePathStatus)
+ * @param context 액션 컨텍스트($event 로 파일 접근)
+ */
+export async function uploadTemplateImageHandler(
+ action: ActionWithParams,
+ context: ActionContext,
+): Promise {
+ const G7Core = (window as any).G7Core;
+ const params = action.params ?? {};
+ const stateTarget = String(params.stateTarget ?? 'global');
+ const pathUrl = String(params.statePathUrl ?? 'bz_tpl_modal.content.templateImageUrl');
+ const pathName = String(params.statePathName ?? 'bz_tpl_modal.content.templateImageName');
+ const pathStatus = String(params.statePathStatus ?? 'bz_tpl_upload');
+
+ const setState = resolveSetter(G7Core, stateTarget);
+ if (!setState) {
+ logger.error('G7Core.state setter 를 사용할 수 없습니다.');
+ return;
+ }
+
+ const event = (context?.event ?? (action as any)?.event) as Event | undefined;
+ const input = event?.target as HTMLInputElement | undefined;
+ const file = input?.files?.[0];
+
+ if (!file) {
+ return;
+ }
+
+ // 진행 중인 업로드가 있으면 새 선택을 받지 않는다. 두 요청이 겹치면 먼저 끝난 쪽이
+ // uploading:false 를 써서 저장 버튼 잠금이 풀리고, 나중에 끝나는 업로드가 저장 이후에
+ // templateImageUrl 을 덮는다 — 잠금이 막으려던 "빈/엉뚱한 URL 저장" 이 그대로 재현된다.
+ if (uploadInFlight) {
+ logger.warn('이미 업로드가 진행 중입니다 — 새 파일 선택을 무시합니다.');
+ if (input) {
+ input.value = '';
+ }
+
+ return;
+ }
+
+ uploadInFlight = true;
+ setState({ [`${pathStatus}.uploading`]: true, [`${pathStatus}.error`]: null });
+
+ try {
+ const token = localStorage.getItem('auth_token') ?? '';
+ const form = new FormData();
+ form.append('image', file, file.name);
+
+ const res = await fetch(UPLOAD_URL, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...localeHeader(),
+ },
+ body: form,
+ });
+
+ const json = await res.json().catch(() => ({}));
+
+ if (!res.ok || json?.success === false) {
+ // 서버가 내려준 사유(카카오 원문·검증 메시지)는 그대로 보여준다 —
+ // Accept-Language 를 실어 보냈으므로 운영자 로케일로 도착한다.
+ const message = json?.errors?.bizppurio_message
+ || json?.errors?.image?.[0]
+ || json?.message
+ || failureText(G7Core);
+ if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
+ failUpload(setState, pathStatus, pathUrl, pathName, message);
+ }
+ logger.warn('이미지 업로드 실패:', message);
+ return;
+ }
+
+ const url = String(json?.data?.url ?? '').trim();
+
+ // 성공 응답인데 url 이 비어 있으면 성공이 아니다 — 그대로 두면 배너는 성공으로 보이는데
+ // templateImageUrl 이 빈 채로 저장돼 검수 신청 시점에야 드러난다.
+ if (url === '') {
+ if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
+ failUpload(setState, pathStatus, pathUrl, pathName, failureText(G7Core));
+ }
+ logger.warn('이미지 업로드 응답에 url 이 없습니다:', json);
+ return;
+ }
+
+ if (!stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
+ logger.warn('모달이 다시 시딩되어 업로드 결과를 버립니다:', url);
+
+ return;
+ }
+
+ setState({
+ [`${pathStatus}.uploading`]: false,
+ [`${pathStatus}.error`]: null,
+ [pathUrl]: url,
+ [pathName]: file.name,
+ });
+
+ logger.log('이미지 업로드 완료:', url);
+ } catch (e) {
+ // 예외 원문(TypeError 등)은 운영자에게 의미가 없고 내부 구현을 노출한다 —
+ // 화면에는 번역 문구만 싣고 원문은 콘솔로만 남긴다.
+ if (stillOwnsUpload(G7Core, stateTarget, pathStatus)) {
+ failUpload(setState, pathStatus, pathUrl, pathName, failureText(G7Core));
+ }
+ logger.error('이미지 업로드 예외:', e);
+ } finally {
+ uploadInFlight = false;
+
+ // 같은 파일 재선택이 가능하도록 input 값 초기화
+ if (input) {
+ input.value = '';
+ }
+ }
+}
+
+/**
+ * 업로드 실패 상태를 기록하고 직전 이미지 값을 무효화합니다.
+ *
+ * 실패 경로가 url/파일명을 그대로 두면, 배너는 "실패" 인데 폼에는 직전에 성공한 이미지가
+ * 남아 그대로 저장된다 — 운영자는 방금 고른 이미지가 저장된다고 믿는다. 실패했으면
+ * 화면에 보이는 이미지도 없어야 한다.
+ *
+ * @param setState 상태 setter
+ * @param pathStatus 업로드 상태 경로 (uploading/error)
+ * @param pathUrl 이미지 URL 상태 경로
+ * @param pathName 이미지 파일명 상태 경로
+ * @param message 화면에 표시할 실패 사유
+ */
+function failUpload(
+ setState: (patch: Record) => void,
+ pathStatus: string,
+ pathUrl: string,
+ pathName: string,
+ message: string,
+): void {
+ setState({
+ [`${pathStatus}.uploading`]: false,
+ [`${pathStatus}.error`]: message,
+ [pathUrl]: '',
+ [pathName]: '',
+ });
+}
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..02e0e40b
--- /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}` 네임스페이스를 갖는다.
+ * (현재 등록 핸들러: insertVariable, uploadTemplateImage — 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..269433fd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/en.json
@@ -0,0 +1,304 @@
+{
+ "name": "Bizppurio Messaging",
+ "description": "Bizppurio SMS/LMS and Kakao Alimtalk delivery plugin.",
+ "settings": {
+ "title": "Bizppurio Messaging Settings",
+ "description": "Manage Bizppurio integration and delivery settings.",
+ "save": "Save",
+ "saving": "Saving...",
+ "save_success": "Settings saved.",
+ "save_failed": "Failed to save settings.",
+ "test_mode": {
+ "label": "Test Mode",
+ "hint": "In test mode, messages are sent to the Bizppurio test domain. Turn it off to send via the production domain.",
+ "account_notice": "We recommend using separate Bizppurio accounts for testing and production."
+ },
+ "live_mode_warning_title": "Sending to production",
+ "live_mode_warning_body": "Test mode is off — real customers will receive messages and delivery costs will be billed. Keep test mode on during inspection.",
+ "sections": {
+ "api": {
+ "title": "API Integration",
+ "description": "Credentials shared by the sending system and the Kakao management system."
+ },
+ "sending": {
+ "title": "Sending Settings",
+ "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": "Enter the Bizppurio module password. (Bizppurio console > Module integration settings > Change module password)"
+ },
+ "connection_check": {
+ "label": "Connection Check",
+ "hint": "Verify that the saved ID and password are valid. (Applies after saving)",
+ "button": "Check Connection",
+ "checking": "Checking...",
+ "success": "Authentication verified. The ID and password are correct.",
+ "failed": "Authentication check failed.",
+ "unsaved_changes": "Please save your changes first."
+ },
+ "api_key": {
+ "label": "API Key",
+ "hint": "Request the API key from Bizppurio customer support along with your account ID."
+ },
+ "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 requests."
+ }
+ },
+ "report": {
+ "section_title": "Report Receiving",
+ "hint": "Bizppurio pushes delivery results (URL PUSH) to this address after sending. Register the address below with the Bizppurio business team (or the report settings in the console).",
+ "note": "Once registered, delivery results are recorded automatically. Before registration, only the send request is confirmed and final results are not updated.",
+ "copy": "Copy",
+ "copied": "Report receiving address copied to clipboard."
+ },
+ "tabs": {
+ "connection": "Settings",
+ "templates": "Notification Templates"
+ },
+ "preparation": {
+ "intro": "To send SMS and Kakao alimtalk, complete the preparations below in the Bizppurio console first.",
+ "sms_label": "SMS/LMS",
+ "sms_sender": "Register a sender number.",
+ "kakao_label": "Kakao Alimtalk",
+ "kakao_channel": "Create a KakaoTalk business channel and register a sender profile.",
+ "kakao_template": "Alimtalk templates are composed and submitted for inspection in the admin notification settings (Bizppurio tab).",
+ "kakao_apikey": "Request an API key from customer support.",
+ "console_link": "Open Bizppurio Console"
+ }
+ },
+ "channels": {
+ "bizppurio_tab": "Bizppurio"
+ },
+ "binding": {
+ "variables_hint": "Available variables (click to insert at the cursor)"
+ },
+ "banner": {
+ "not_ready": "Required delivery settings are not complete.",
+ "setup_action": "Set up",
+ "test_mode": "Test mode — no real messages are sent."
+ },
+ "dispatch_result": {
+ "column_header": "SMS/Alimtalk Result",
+ "inspection_label": "Test",
+ "low_balance": "Low balance",
+ "fallback": "SMS fallback {status}",
+ "detail_title": "SMS/Alimtalk Delivery Result",
+ "channel_label": "Channel: {channel}",
+ "sent_content_label": "Sent content",
+ "sent_content_hint": "Alimtalk is sent with the approved template content, which may differ from the body above."
+ },
+ "editor": {
+ "data_source": {
+ "dispatch_results": "Bizppurio dispatch results"
+ }
+ },
+ "template": {
+ "tab_guide": "Open [Edit] on each notification to write the alimtalk template and the SMS body together and request review. Alimtalk is sent automatically after Kakao approval; fallback SMS and SMS-only sending are configured in the same window.",
+ "status": {
+ "unwritten": "Not composed",
+ "draft": "Draft",
+ "requested": "Under inspection",
+ "approved": "Approved",
+ "rejected": "Rejected",
+ "stopped": "Stopped",
+ "blocked": "Blocked",
+ "dormant": "Dormant"
+ },
+ "row": {
+ "alimtalk_label": "Alimtalk",
+ "requested_on": "Requested {date}",
+ "synced_on": "Synced {date}",
+ "alimtalk_enabled": "Enabled",
+ "btn_view_reason": "View reason",
+ "btn_compose": "Compose alimtalk template",
+ "btn_edit": "Edit",
+ "btn_edit_approved": "Edit (cancel approval)",
+ "btn_request": "Request inspection",
+ "btn_cancel_request": "Cancel request",
+ "btn_release": "Release dormant",
+ "btn_refresh": "Refresh",
+ "requested_toast": "Inspection requested.",
+ "cancel_request_toast": "Inspection request cancelled.",
+ "released_toast": "Dormant state released.",
+ "synced_toast": "Kakao inspection status synchronized.",
+ "fallback_sms": "SMS fallback",
+ "sms_only": "SMS only",
+ "sms_body_prefix": "Body:",
+ "sms_body_missing": "No SMS body",
+ "btn_edit_sms": "Edit SMS body",
+ "sms_label": "SMS",
+ "on": "On",
+ "off": "Off",
+ "alimtalk_disabled": "Sending off"
+ },
+ "form": {
+ "modal_title": "Alimtalk Template — {name}",
+ "rejected_banner": "This template was rejected. Review the reasons below, edit, and request inspection again.",
+ "sender_profile": "Sender profile",
+ "sender_profile_placeholder": "Select a sender profile (reference)",
+ "sender_profile_hint": "Inspection requests use the sender profile key saved in the plugin settings.",
+ "category": "Category",
+ "category_placeholder": "Select a category",
+ "name": "Template name",
+ "message_type": "Message type",
+ "templateMessageType_options": {
+ "BA": "Basic",
+ "EX": "Extra info",
+ "AD": "Channel add",
+ "MI": "Mixed"
+ },
+ "emphasize_type": "Emphasis type",
+ "templateEmphasizeType_options": {
+ "NONE": "None",
+ "TEXT": "Text",
+ "IMAGE": "Image",
+ "ITEM_LIST": "Item list"
+ },
+ "emphasize_title": "Emphasis title",
+ "emphasize_subtitle": "Emphasis subtitle",
+ "image": "Image",
+ "image_hint": "jpg/png · up to 500KB · width ≥500px · 2:1 ratio (the URL is filled automatically after upload)",
+ "image_uploading": "Uploading image...",
+ "image_upload_failed": "Failed to upload the image.",
+ "content": "Body",
+ "extra": "Extra info",
+ "header": "Header",
+ "header_hint": "Optional header shown above the body (up to 16 characters)",
+ "buttons": "Buttons (up to 5)",
+ "button_add": "Add button",
+ "button_name": "Button name (up to 14 chars)",
+ "quick_replies": "Quick replies (up to 10)",
+ "quick_reply_add": "Add quick reply",
+ "link_mo": "Mobile link (required)",
+ "link_pc": "PC link (optional)",
+ "link_and": "Android scheme (required)",
+ "link_ios": "iOS scheme (required)",
+ "tel_number": "Phone number (required)",
+ "plugin_id": "Plugin ID (required)",
+ "item_highlight": "Item highlight (optional)",
+ "hl_title": "Highlight title",
+ "hl_description": "Highlight description",
+ "hl_image_url": "Thumbnail image URL (optional)",
+ "item_list": "Items (2–10)",
+ "item_list_hint": "At least 2 and up to 10 items. (title ≤6 chars, description ≤23 chars)",
+ "item_add": "Add item",
+ "item_title": "Title",
+ "item_description": "Description",
+ "item_summary": "Summary (price info)",
+ "summary_description": "Summary value (≤14 chars)",
+ "btn_remove_item": "Remove item",
+ "btn_remove_button": "Remove button",
+ "btn_remove_quick_reply": "Remove quick reply",
+ "btn_save": "Save",
+ "btn_save_request": "Save & request inspection",
+ "request_comment": "Note to reviewer",
+ "request_comment_placeholder": "e.g. #{name}=Jane Doe, #{order_number}=20260823-0001",
+ "request_comment_hint": "Sent to the Kakao reviewer together with the inspection request (optional, up to 500 characters). Kakao review guidelines ask for an example value per body variable, so listing them here reduces rejections. It is not stored — it is only sent when you request inspection.",
+ "saved_toast": "Notification template saved.",
+ "requested_toast": "Inspection requested. Alimtalk delivery starts once approved."
+ },
+ "sms": {
+ "modal_title": "SMS Body — {name}",
+ "hint": "This body is shared by SMS fallback and SMS-only delivery. #{variables} are substituted at send time.",
+ "btn_save": "Save",
+ "saved_toast": "SMS body saved."
+ },
+ "cancel_approval": {
+ "modal_title": "Cancel Approval",
+ "warning": "Cancelling approval of '{name}' immediately stops alimtalk delivery for this notification. (SMS fallback and SMS-only delivery continue per the SMS settings.) Alimtalk resumes only after you edit, request inspection again, and get approved.",
+ "btn_confirm": "Cancel approval & edit",
+ "done_toast": "Approval cancelled. Edit and request inspection again."
+ },
+ "rejection": {
+ "modal_title": "Rejection Reasons — {name}",
+ "btn_close": "Close"
+ },
+ "approval": {
+ "approved": "Approved",
+ "not_approved": "Not approved"
+ },
+ "section": {
+ "alimtalk_title": "Alimtalk template",
+ "sms_title": "SMS",
+ "alimtalk_enabled": "Send alimtalk",
+ "locked_hint": "Templates under review or already approved cannot be edited. Press [Cancel request] or [Edit (cancel approval)] first.",
+ "saved_toast": "Notification template saved.",
+ "loading": "Loading template…"
+ }
+ },
+ "manage": {
+ "guide": "Manage the Bizppurio template status of every notification at a glance. Composing and requesting inspection work the same as in the notification settings screen.",
+ "filter_all": "All statuses",
+ "search_placeholder": "Search by notification name/type",
+ "btn_search": "Search",
+ "total_count": "Total {count}",
+ "btn_compose": "Compose",
+ "btn_edit": "Edit",
+ "btn_delete": "Delete",
+ "empty": "No Bizppurio notification templates yet.",
+ "empty_hint": "Templates appear here once you compose an alimtalk template or configure SMS in the admin notification settings (Bizppurio tab).",
+ "sms_only": "Only",
+ "sms_fallback": "Fallback",
+ "owner": {
+ "core": "Core",
+ "module": "Module",
+ "plugin": "Plugin"
+ },
+ "columns": {
+ "notification": "Notification",
+ "owner": "Owner",
+ "status": "Alimtalk Status",
+ "sms": "SMS",
+ "requested_at": "Requested",
+ "synced_at": "Synced",
+ "actions": "Actions"
+ },
+ "delete": {
+ "modal_title": "Delete Template",
+ "warning": "Delete the Bizppurio template settings of '{name}'. The Kakao-side template is deleted together only when it is in a deletable state (registered/rejected).",
+ "btn_confirm": "Delete",
+ "done_with_kakao": "Template deleted. (including the Kakao-side template)",
+ "done_db_only": "Template settings deleted. The Kakao-side template remains due to its state."
+ }
+ },
+ "templates": {
+ "readiness": {
+ "title": "The settings below are required to use alimtalk templates",
+ "go_settings": "Go to settings",
+ "missing_label": "Missing items",
+ "api_key_missing": "Kakao management API key",
+ "sender_key_missing": "Alimtalk sender profile key",
+ "note": "Enter the items above in the Settings tab to compose templates and request inspection. Real delivery requires turning off test mode and an approved template."
+ },
+ "list": {
+ "refresh": "Refresh"
+ },
+ "link_type": {
+ "WL": "Web link",
+ "AL": "App link",
+ "DS": "Delivery tracking",
+ "BK": "Bot keyword",
+ "MD": "Message forward",
+ "AC": "Add channel",
+ "BC": "Consult talk",
+ "BT": "Bot transfer",
+ "TN": "Call",
+ "MP": "Map",
+ "P1": "Secure image",
+ "P2": "Personal info",
+ "P3": "One-click pay"
+ }
+ }
+}
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..1c06fbfc
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/lang/ko.json
@@ -0,0 +1,304 @@
+{
+ "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자)입니다."
+ }
+ },
+ "report": {
+ "section_title": "리포트 수신 설정",
+ "hint": "비즈뿌리오는 문자·알림톡 발송 후 성공/실패 결과를 이 주소로 전송(URL PUSH)하는 리포트 수신 방식을 제공합니다. 아래 주소를 비즈뿌리오 사업팀(또는 관리 콘솔의 리포트 수신 설정)에 등록하세요.",
+ "note": "등록하면 발송 결과가 이력에 자동 기록되어 실제 도달 여부를 확인할 수 있습니다. 등록 전에는 발송 요청까지만 확인되고 최종 결과(성공/실패)는 갱신되지 않습니다.",
+ "copy": "복사",
+ "copied": "리포트 수신 주소가 클립보드에 복사되었습니다."
+ },
+ "tabs": {
+ "connection": "환경설정",
+ "templates": "알림 템플릿 관리"
+ },
+ "preparation": {
+ "intro": "문자·카카오 알림톡을 발송하려면 먼저 비즈뿌리오 콘솔에서 아래 사전 준비를 완료해야 합니다.",
+ "sms_label": "문자(SMS/LMS)",
+ "sms_sender": "발신번호를 등록하세요.",
+ "kakao_label": "카카오 알림톡",
+ "kakao_channel": "카카오톡 비즈니스 채널을 만들고 발신프로필을 등록하세요.",
+ "kakao_template": "알림톡 템플릿은 관리자 알림 설정(비즈뿌리오 탭)에서 작성해 검수를 신청합니다.",
+ "kakao_apikey": "고객센터에 API 키를 요청하세요.",
+ "console_link": "비즈뿌리오 콘솔 열기"
+ }
+ },
+ "channels": {
+ "bizppurio_tab": "비즈뿌리오"
+ },
+ "binding": {
+ "variables_hint": "제공 변수 (클릭하면 본문 커서 위치에 삽입됩니다)"
+ },
+ "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": "비즈뿌리오 발송 결과"
+ }
+ },
+ "template": {
+ "tab_guide": "각 알림의 [편집]에서 알림톡 템플릿과 문자(SMS) 본문을 함께 작성하고 검수를 신청하세요. 카카오 승인 후 알림톡이 자동 발송되며, 대체 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 본문 편집",
+ "sms_label": "문자(SMS)",
+ "on": "사용",
+ "off": "미사용",
+ "alimtalk_disabled": "발송 사용 안 함"
+ },
+ "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": "저장 후 검수 신청",
+ "request_comment": "검수자 전달 의견",
+ "request_comment_placeholder": "예: #{name}=홍길동, #{order_number}=20260823-0001",
+ "request_comment_hint": "검수 신청 시 카카오 검수자에게 함께 전달됩니다(선택, 500자 이내). 카카오 심사 가이드는 본문 변수마다 예시값을 요구하므로 변수별 예시값을 적어 두면 반려를 줄일 수 있습니다. 저장되지 않으며 신청할 때만 전송됩니다.",
+ "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": "닫기"
+ },
+ "approval": {
+ "approved": "승인됨",
+ "not_approved": "미승인"
+ },
+ "section": {
+ "alimtalk_title": "알림톡 템플릿",
+ "sms_title": "문자(SMS)",
+ "alimtalk_enabled": "알림톡 발송 사용",
+ "locked_hint": "검수중이거나 승인된 템플릿은 내용을 수정할 수 없습니다. 수정하려면 [신청 취소] 또는 [수정 (승인 취소)]를 먼저 누르세요.",
+ "saved_toast": "알림 템플릿을 저장했습니다.",
+ "loading": "템플릿 내용을 불러오는 중…"
+ }
+ },
+ "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": {
+ "readiness": {
+ "title": "알림톡 템플릿을 사용하려면 아래 설정이 필요합니다",
+ "go_settings": "환경설정으로 이동",
+ "missing_label": "미설정 항목",
+ "api_key_missing": "카카오 관리 API 키",
+ "sender_key_missing": "알림톡 발신프로필 키",
+ "note": "위 항목을 환경설정 탭에서 입력하면 템플릿 작성·검수 신청이 가능합니다. 실제 발송은 검수 모드를 끄고 운영으로 전환한 뒤 승인된 템플릿만 사용할 수 있습니다."
+ },
+ "list": {
+ "refresh": "새로고침"
+ },
+ "link_type": {
+ "WL": "웹링크",
+ "AL": "앱링크",
+ "DS": "배송조회",
+ "BK": "봇키워드",
+ "MD": "메시지전달",
+ "AC": "채널추가",
+ "BC": "상담톡전환",
+ "BT": "봇전환",
+ "TN": "전화하기",
+ "MP": "지도보기",
+ "P1": "이미지 보안전송",
+ "P2": "개인정보이용",
+ "P3": "원클릭 결제"
+ }
+ }
+}
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..e1867170
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/resources/layouts/admin/plugin_settings.json
@@ -0,0 +1,5274 @@
+{
+ "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 방침에 따라 직접 수행(인증·발송 환경 필요) 단계에서 도입 예정 — tests/scenarios/plugin-settings.yaml 상단 audit:allow 사유 참고."
+ },
+ "init_actions": [
+ {
+ "if": "{{(query.tab ?? 'connection') === 'templates'}}",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ }
+ ],
+ "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": "bizppurio_templates_list",
+ "comment": "비즈뿌리오 알림 템플릿 DB 목록(#597 §4.3). 필터·검색·페이지는 URL 쿼리 SSoT(bz_status/bz_search/bz_page).",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/templates",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "params": {
+ "status": "{{query.bz_status ?? ''}}",
+ "search": "{{query.bz_search ?? ''}}",
+ "page": "{{query.bz_page ?? 1}}",
+ "per_page": 20
+ }
+ },
+ {
+ "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'}}"
+ },
+ {
+ "id": "bizppurioCategories",
+ "comment": "작성 모달 카테고리 셀렉트(kapi category/all 위임). 모달 열 때만 조회. 실패는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "categories": [],
+ "load_failed": true
+ }
+ }
+ },
+ {
+ "id": "bizppurioProfiles",
+ "comment": "작성 모달 발신프로필 참고 셀렉트(kapi profile/use 위임). 모달 열 때만 조회. 실패는 suppress + fallback.",
+ "type": "api",
+ "endpoint": "/api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles",
+ "method": "GET",
+ "auto_fetch": false,
+ "auth_required": true,
+ "errorHandling": {
+ "default": {
+ "handler": "suppress"
+ }
+ },
+ "fallback": {
+ "data": {
+ "profiles": [],
+ "load_failed": true
+ }
+ }
+ }
+ ],
+ "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": "bizppurio_templates_list"
+ }
+ }
+ ]
+ }
+ ],
+ "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": "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_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_manage_view",
+ "comment": "비즈뿌리오 알림 템플릿 관리(#597 §4.3) — DB 목록(정의 라벨 조인). 필터·검색·페이지는 URL 쿼리(bz_status/bz_search/bz_page, mergeQuery 왕복 규약). 행 액션은 알림 설정 탭 행 하단과 동일 의미(작성 모달·SMS 모달·승인취소·삭제 공유).",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{templates_readiness?.data?.ready}}",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "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",
+ "text": "$t:sirsoft-message_bizppurio.manage.guide",
+ "props": {
+ "className": "text-sm text-blue-800 dark:text-blue-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex items-center gap-2 mb-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-40 text-sm flex-shrink-0",
+ "value": "{{query.bz_status ?? ''}}",
+ "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.manage.filter_all')}, ...['draft','requested','approved','rejected','stopped','blocked','dormant'].map(s => ({value: s, label: $t('sirsoft-message_bizppurio.template.status.' + s)}))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "sequence",
+ "actions": [
+ {
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/{{route.identifier}}/settings",
+ "replace": true,
+ "mergeQuery": true,
+ "query": {
+ "bz_status": "{{$event.target.value}}",
+ "bz_page": ""
+ }
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ }
+ ]
+ }
+ ],
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "flex-1 min-w-0 text-sm"
+ }
+ }
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "flex-1 min-w-0 px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "placeholder": "$t:sirsoft-message_bizppurio.manage.search_placeholder",
+ "value": "{{_local.bzSearchDraft ?? (query.bz_search ?? '')}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "local",
+ "bzSearchDraft": "{{$event.target.value}}"
+ }
+ }
+ ],
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100"
+ }
+ }
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "btn btn-primary whitespace-nowrap flex-shrink-0"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/{{route.identifier}}/settings",
+ "replace": true,
+ "mergeQuery": true,
+ "query": {
+ "bz_search": "{{_local.bzSearchDraft ?? ''}}",
+ "bz_page": ""
+ }
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.manage.btn_search"
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2 flex-wrap flex-1 min-w-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "aria-label": "$t:sirsoft-message_bizppurio.templates.list.refresh",
+ "className": "btn-icon flex-shrink-0"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ }
+ ],
+ "comment": "새로고침 — 코어 관리자 목록 관례(btn-icon + 회전 아이콘)와 통일 (제품 결정 2026-08-24).",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-arrows-rotate",
+ "className": "text-base"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex-between gap-2 mb-3"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.manage.total_count|count={{bizppurio_templates_list?.data?.pagination?.total ?? 0}}",
+ "props": {
+ "className": "text-label-subtle mb-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "id": "bz_manage_list_header",
+ "type": "basic",
+ "name": "Div",
+ "comment": "데스크톱 grid 헤더 — portable 에서는 카드가 자기 서술이므로 숨긴다 (제품 결정 2026-08-24 모바일 카드뷰).",
+ "props": {
+ "className": "grid grid-cols-12 gap-2 px-4 py-2.5 bg-gray-50 dark:bg-gray-900 text-xs font-medium text-gray-500 dark:text-gray-400"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "hidden"
+ }
+ }
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-3"
+ },
+ "text": "$t:sirsoft-message_bizppurio.manage.columns.notification"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-2"
+ },
+ "text": "$t:sirsoft-message_bizppurio.manage.columns.owner"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.manage.columns.status"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.manage.columns.sms"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.manage.columns.requested_at"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.manage.columns.synced_at"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-3 text-right"
+ },
+ "text": "$t:sirsoft-message_bizppurio.manage.columns.actions"
+ }
+ ]
+ },
+ {
+ "id": "bz_manage_list_rows",
+ "type": "basic",
+ "name": "Div",
+ "if": "{{(bizppurio_templates_list?.data?.templates ?? []).length > 0}}",
+ "props": {
+ "className": "divide-y divide-gray-100 dark:divide-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "comment": "반복 노드에는 responsive 를 두지 않는다(저장소 관례 — 실측상 반복 노드 위 오버라이드는 화면 전환에 반영되지 않아, 클래스·responsive 는 내부 래퍼가 담당한다. 2026-08-24 Chrome 실측).",
+ "iteration": {
+ "source": "{{bizppurio_templates_list?.data?.templates ?? []}}",
+ "item_var": "bzRow",
+ "index_var": "bzRowIdx"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "comment": "행 — 데스크톱 grid 12열, portable 에서 카드(flex-wrap: 이름 전폭 → 배지 줄 → 메타 줄 → 액션 전폭).",
+ "props": {
+ "className": "grid grid-cols-12 gap-2 px-4 py-3 items-center hover:bg-gray-50 dark:hover:bg-gray-800/50"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "flex flex-wrap gap-2 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50"
+ }
+ }
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-3"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "w-full"
+ }
+ }
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}",
+ "props": {
+ "className": "font-medium"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "{{bzRow.notification_type}}",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "comment": "카드 메타 줄 — 데스크톱에서 별도 열인 소속·신청일·동기화일을 portable 카드에서 한 줄로 요약.",
+ "props": {
+ "className": "hidden"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
+ }
+ }
+ },
+ "text": "{{$t('sirsoft-message_bizppurio.manage.owner.' + (bzRow.definition_extension_type ?? 'core'))}} {{(bzRow.definition_extension_type ?? 'core') === 'core' ? '' : ('(' + (bzRow.definition_extension_identifier ?? '') + ')')}} · {{$t('sirsoft-message_bizppurio.manage.columns.requested_at')}} {{(bzRow.requested_at ?? '').slice(0, 10) || '-'}} · {{$t('sirsoft-message_bizppurio.manage.columns.synced_at')}} {{(bzRow.last_synced_at ?? '').slice(0, 10) || '-'}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-2 text-sm text-gray-700 dark:text-gray-200"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "hidden"
+ }
+ }
+ },
+ "text": "{{$t('sirsoft-message_bizppurio.manage.owner.' + (bzRow.definition_extension_type ?? 'core'))}} {{(bzRow.definition_extension_type ?? 'core') === 'core' ? '' : ('(' + (bzRow.definition_extension_identifier ?? '') + ')')}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{$t('sirsoft-message_bizppurio.template.status.' + (bzRow.has_content ? bzRow.status : 'unwritten'))}}",
+ "props": {
+ "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ' + (((bzRow.has_content ? bzRow.status : 'unwritten')) === 'approved' ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'requested' ? 'bg-amber-100 dark:bg-amber-900 text-amber-700 dark:text-amber-200' : ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'rejected' ? 'bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-100' : ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'dormant' ? 'bg-orange-100 dark:bg-orange-900 text-orange-700 dark:text-orange-200' : (((bzRow.has_content ? bzRow.status : 'unwritten')) === 'stopped' || ((bzRow.has_content ? bzRow.status : 'unwritten')) === 'blocked') ? 'border border-red-400 dark:border-red-500 text-red-600 dark:text-red-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1"
+ },
+ "comment": "SMS 발송 모드 배지 — 단독/대체(초록), 미사용(회색). 행 하단 상태 배지와 같은 규칙(제품 결정 2026-08-23).",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Span",
+ "props": {
+ "className": "{{'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ' + ((bzRow.sms_only || bzRow.fallback_sms_enabled) ? 'bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-200' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300')}}"
+ },
+ "text": "{{bzRow.sms_only ? $t('sirsoft-message_bizppurio.manage.sms_only') : (bzRow.fallback_sms_enabled ? $t('sirsoft-message_bizppurio.manage.sms_fallback') : $t('sirsoft-message_bizppurio.template.row.off'))}}"
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1 text-sm text-gray-700 dark:text-gray-200 tabular-nums"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "hidden"
+ }
+ }
+ },
+ "text": "{{(bzRow.requested_at ?? '').slice(0, 10) || '-'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-1 text-sm text-gray-700 dark:text-gray-200 tabular-nums"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "hidden"
+ }
+ }
+ },
+ "text": "{{(bzRow.last_synced_at ?? '').slice(0, 10) || '-'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "col-span-3"
+ },
+ "responsive": {
+ "portable": {
+ "props": {
+ "className": "w-full pt-1"
+ }
+ }
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_reject_view": {
+ "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}",
+ "comments": []
+ }
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}",
+ "params": {
+ "method": "GET"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_reject_view.comments": "{{response.data?.template?.inspection_detail ?? []}}"
+ }
+ },
+ {
+ "handler": "openModal",
+ "target": "modal_bizppurio_rejection"
+ }
+ ],
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_view_reason",
+ "if": "{{bzRow.status === 'rejected'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 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"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_upload": {
+ "uploading": false,
+ "error": null
+ },
+ "bz_tpl_modal": {
+ "id": "{{bzRow.id}}",
+ "notification_type": "{{bzRow.notification_type}}",
+ "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}",
+ "variables": "{{bzRow.definition_variables ?? []}}",
+ "status": "{{bzRow.status}}",
+ "inspection_detail": [],
+ "isSaving": false,
+ "error": null,
+ "sender_profile": "",
+ "content": {
+ "templateName": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}",
+ "templateMessageType": "BA",
+ "templateEmphasizeType": "NONE",
+ "templateContent": "",
+ "templateExtra": "",
+ "templateHeader": "",
+ "templateTitle": "",
+ "templateSubtitle": "",
+ "templateImageName": "",
+ "templateImageUrl": "",
+ "categoryCode": "",
+ "buttons": [],
+ "quickReplies": [],
+ "templateItem": {
+ "list": [],
+ "summary": {
+ "title": "",
+ "description": ""
+ }
+ },
+ "templateItemHighlight": {
+ "title": "",
+ "description": "",
+ "imageUrl": ""
+ }
+ },
+ "request_comment": ""
+ }
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}",
+ "params": {
+ "method": "GET"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.status": "{{response.data?.template?.status}}",
+ "bz_tpl_modal.inspection_detail": "{{response.data?.template?.inspection_detail ?? []}}",
+ "bz_tpl_modal.content": "{{Object.assign({templateName: '', templateMessageType: 'BA', templateEmphasizeType: 'NONE', templateContent: '', templateExtra: '', templateHeader: '', templateTitle: '', templateSubtitle: '', templateImageName: '', templateImageUrl: '', categoryCode: '', buttons: [], quickReplies: [], templateItem: {list: [], summary: {title: '', description: ''}}, templateItemHighlight: {title: '', description: '', imageUrl: ''}}, JSON.parse(JSON.stringify(response.data?.template?.content ?? {})))}}"
+ }
+ },
+ {
+ "handler": "openModal",
+ "target": "modal_bizppurio_template"
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioCategories"
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurioProfiles"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "comment": "라벨은 내용 유무로 갈린다 — 행 하단(row_footer)이 미작성 행에 '작성', 작성된 행에 '수정' 을 그리는 것과 같은 어휘. 상태 배지도 같은 has_content 분기를 쓴다(:1916).",
+ "text": "{{$t('sirsoft-message_bizppurio.manage.' + (bzRow.has_content ? 'btn_edit' : 'btn_compose'))}}",
+ "if": "{{bzRow.status === 'draft' || bzRow.status === 'rejected'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_cancel_approval": {
+ "id": "{{bzRow.id}}",
+ "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}",
+ "isSaving": false
+ }
+ }
+ },
+ {
+ "handler": "openModal",
+ "target": "modal_bizppurio_cancel_approval"
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_approved",
+ "if": "{{bzRow.status === 'approved'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 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",
+ "disabled": "{{_global.bz_row_busy ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_row_busy": true
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/request",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_row_busy": false
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.row.requested_toast",
+ "type": "success"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_row_busy": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "if": "{{!(_global.bz_row_busy ?? false)}}"
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_request",
+ "if": "{{bzRow.status === 'draft' && bzRow.has_content}}"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/cancel-request",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.row.cancel_request_toast",
+ "type": "success"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_cancel_request",
+ "if": "{{bzRow.status === 'requested'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 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"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/release",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.row.released_toast",
+ "type": "success"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_release",
+ "if": "{{bzRow.status === 'dormant'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}/sync",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.row.synced_toast",
+ "type": "success"
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_refresh",
+ "if": "{{bzRow.template_code}}"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal": {
+ "notification_type": "{{bzRow.notification_type}}",
+ "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}",
+ "variables": "{{bzRow.definition_variables ?? []}}",
+ "body": "",
+ "isSaving": false
+ }
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{bzRow.id}}",
+ "params": {
+ "method": "GET"
+ },
+ "onSuccess": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.body": "{{Object.assign({}, response.data?.template?.sms_body)}}",
+ "bz_sms_modal.editLang": "{{$locale}}"
+ }
+ },
+ {
+ "handler": "openModal",
+ "target": "modal_bizppurio_sms"
+ }
+ ],
+ "onError": [
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.row.btn_edit_sms"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-red-300 dark:border-red-600 text-red-700 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_delete_modal": {
+ "id": "{{bzRow.id}}",
+ "notification_name": "{{bzRow.definition_name?.[$locale] ?? bzRow.notification_type}}",
+ "isSaving": false
+ }
+ }
+ },
+ {
+ "handler": "openModal",
+ "target": "modal_bizppurio_delete"
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.manage.btn_delete"
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-1 flex-wrap justify-end"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-inbox",
+ "className": "text-3xl text-gray-300 dark:text-gray-600 mb-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.manage.empty",
+ "props": {
+ "className": "font-medium"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.manage.empty_hint",
+ "props": {
+ "className": "text-xs mt-1"
+ }
+ }
+ ],
+ "props": {
+ "className": "py-10 text-center text-gray-500 dark:text-gray-400"
+ },
+ "if": "{{(bizppurio_templates_list?.data?.templates ?? []).length === 0}}"
+ }
+ ],
+ "props": {
+ "className": "admin-card p-0 overflow-hidden"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "composite",
+ "name": "Pagination",
+ "props": {
+ "currentPage": "{{bizppurio_templates_list?.data?.pagination?.current_page ?? 1}}",
+ "totalPages": "{{bizppurio_templates_list?.data?.pagination?.last_page ?? null}}",
+ "hasMorePages": "{{bizppurio_templates_list?.data?.pagination?.has_more_pages ?? false}}"
+ },
+ "actions": [
+ {
+ "event": "onPageChange",
+ "type": "action",
+ "handler": "sequence",
+ "actions": [
+ {
+ "handler": "navigate",
+ "params": {
+ "path": "/admin/plugins/{{route.identifier}}/settings",
+ "replace": true,
+ "mergeQuery": true,
+ "query": {
+ "bz_page": "{{$args[0]}}"
+ }
+ }
+ },
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex justify-center mt-4"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ "modals": [
+ {
+ "id": "modal_bizppurio_template",
+ "comment": "알림톡 템플릿 작성/신청 모달(#597 §4.2, 3면+플러그인 관리 화면 공유). 값은 _global.bz_tpl_modal 에 담는다(모달 컨텍스트 분리). content 는 kapi 등록 페이로드 필드 그대로이며, 유형 전환 잔여 필드는 서버(FormRequest)가 정돈한다. 신규/기존 분기는 id 유무의 저장 버튼 if 2벌(POST/PUT)로 처리한다.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.template.form.modal_title|name={{_global.bz_tpl_modal?.notification_name ?? ''}}",
+ "size": "xl",
+ "closeOnOverlayClick": false
+ },
+ "children": [
+ {
+ "id": "bizppurio_tpl_modal_body",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "space-y-4 max-h-[65vh] overflow-y-auto pr-1"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-circle-exclamation",
+ "size": "sm",
+ "className": "text-red-600 dark:text-red-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-message_bizppurio.template.form.rejected_banner",
+ "props": {
+ "className": "text-xs font-medium text-red-800 dark:text-red-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.inspection_detail ?? []}}",
+ "item_var": "cm"
+ },
+ "props": {
+ "className": "text-xs text-red-700 dark:text-red-300 mt-1"
+ },
+ "text": "{{(cm.createdAt ?? '').slice(0, 10)}} — {{cm.content ?? ''}}"
+ }
+ ],
+ "props": {
+ "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "if": "{{_global.bz_tpl_modal?.status === 'rejected' && (_global.bz_tpl_modal?.inspection_detail ?? []).length > 0}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-triangle-exclamation",
+ "size": "sm",
+ "className": "text-red-600 dark:text-red-400 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{_global.bz_tpl_modal?.error}}",
+ "props": {
+ "className": "text-xs text-red-800 dark:text-red-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "if": "{{_global.bz_tpl_modal?.error}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "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.template.form.sender_profile"
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-full text-sm",
+ "value": "{{_global.bz_tpl_modal?.sender_profile ?? ''}}",
+ "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.sender_profile_placeholder')}, ...((bizppurioProfiles?.data?.profiles ?? []).map(pr => ({value: pr.senderKey ?? '', label: (pr.name ?? pr.senderKey ?? '')})))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.sender_profile": "{{$event.target.value}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.sender_profile_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
+ }
+ }
+ ]
+ },
+ {
+ "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.template.form.category *"
+ },
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-full text-sm",
+ "value": "{{_global.bz_tpl_modal?.content?.categoryCode ?? ''}}",
+ "options": "{{[{value: '', label: $t('sirsoft-message_bizppurio.template.form.category_placeholder')}, ...((bizppurioCategories?.data?.categories ?? []).map(cat => ({value: cat.code ?? '', label: ((cat.groupName ? cat.groupName + ' › ' : '') + (cat.name ?? cat.code ?? ''))})))]}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.categoryCode": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-1 sm:grid-cols-2 gap-3"
+ }
+ },
+ {
+ "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.template.form.name *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateName ?? ''}}",
+ "name": "bz_template_name",
+ "maxLength": 200
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateName": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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.template.form.message_type *"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "comment": "메시지 유형 — BA 기본형 / EX 부가정보형 / AD 채널추가형 / MI 복합형",
+ "props": {
+ "className": "flex flex-wrap gap-1.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'BA' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "BA"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.BA"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'EX' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "EX"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.EX"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'AD' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "AD"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.AD"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateMessageType ?? 'BA') === 'MI' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateMessageType": "MI"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateMessageType_options.MI"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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.template.form.emphasize_type *"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "comment": "강조 유형 — NONE / TEXT 강조표기 / IMAGE 이미지 / ITEM_LIST 아이템리스트",
+ "props": {
+ "className": "flex flex-wrap gap-1.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'NONE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "NONE"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.NONE"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'TEXT' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "TEXT"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.TEXT"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'IMAGE' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "IMAGE"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.IMAGE"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "{{'px-3 py-1.5 rounded-md text-xs font-medium border ' + ((_global.bz_tpl_modal?.content?.templateEmphasizeType ?? 'NONE') === 'ITEM_LIST' ? 'border-teal-500 bg-teal-50 dark:bg-teal-900/40 text-teal-700 dark:text-teal-300' : 'border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700')}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateEmphasizeType": "ITEM_LIST"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.templateEmphasizeType_options.ITEM_LIST"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "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.template.form.emphasize_title *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateTitle ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateTitle": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "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.template.form.emphasize_subtitle *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateSubtitle ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateSubtitle": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-2 gap-3"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'TEXT'}}"
+ },
+ {
+ "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.template.form.image *"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "type": "file",
+ "accept": ".jpg,.jpeg,.png",
+ "className": "block w-full text-xs text-gray-600 dark:text-gray-300"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "sirsoft-message_bizppurio.uploadTemplateImage",
+ "params": {
+ "stateTarget": "global",
+ "statePathUrl": "bz_tpl_modal.content.templateImageUrl",
+ "statePathName": "bz_tpl_modal.content.templateImageName",
+ "statePathStatus": "bz_tpl_upload"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.image_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "$t:sirsoft-message_bizppurio.template.form.image_uploading",
+ "props": {
+ "className": "text-xs text-amber-600 dark:text-amber-400"
+ },
+ "if": "{{_global.bz_tpl_upload?.uploading}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{_global.bz_tpl_upload?.error ?? ''}}",
+ "props": {
+ "className": "text-xs text-red-600 dark:text-red-400"
+ },
+ "if": "{{_global.bz_tpl_upload?.error}}"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{_global.bz_tpl_modal?.content?.templateImageName}} — {{_global.bz_tpl_modal?.content?.templateImageUrl}}",
+ "props": {
+ "className": "text-xs text-green-700 dark:text-green-400 break-all"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateImageUrl}}"
+ }
+ ],
+ "props": {
+ "className": "space-y-1"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'IMAGE'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_highlight"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_title",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.title ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {title: $event.target.value})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_description",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.description ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {description: $event.target.value})}}"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-2 gap-1.5"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.hl_image_url",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItemHighlight?.imageUrl ?? ''}}",
+ "maxLength": 500
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItemHighlight": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItemHighlight ?? {}, {imageUrl: $event.target.value})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_list"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",
+ "disabled": "{{(_global.bz_tpl_modal?.content?.templateItem?.list ?? []).length >= 10}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: [...(_global.bz_tpl_modal?.content?.templateItem?.list ?? []), {title: '', description: ''}]})}}"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_add"
+ }
+ ],
+ "props": {
+ "className": "flex-between mt-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_list_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.content?.templateItem?.list ?? []}}",
+ "item_var": "itm",
+ "index_var": "itmIdx"
+ },
+ "props": {
+ "className": "flex items-center gap-1.5"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 6,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title",
+ "value": "{{itm.title ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {title: $event.target.value}) : r)})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 23,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_description",
+ "value": "{{itm.description ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).map((r, i) => i === itmIdx ? Object.assign({}, r, {description: $event.target.value}) : r)})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_item",
+ "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {list: (_global.bz_tpl_modal?.content?.templateItem?.list ?? []).filter((r, i) => i !== itmIdx)})}}"
+ }
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-trash-can",
+ "size": "sm"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium mt-2"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.item_summary"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 6,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.item_title",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.title ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {title: $event.target.value})})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "maxLength": 14,
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.summary_description",
+ "value": "{{_global.bz_tpl_modal?.content?.templateItem?.summary?.description ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateItem": "{{Object.assign({}, _global.bz_tpl_modal?.content?.templateItem ?? {}, {summary: Object.assign({}, _global.bz_tpl_modal?.content?.templateItem?.summary ?? {}, {description: $event.target.value})})}}"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-2 gap-1.5"
+ }
+ }
+ ],
+ "props": {
+ "className": "space-y-2"
+ },
+ "if": "{{_global.bz_tpl_modal?.content?.templateEmphasizeType === 'ITEM_LIST'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "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.template.form.content *"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{(_global.bz_tpl_modal?.content?.templateContent ?? '').length}}/1000",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "name": "bz_template_content",
+ "rows": 6,
+ "maxLength": 1000,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 font-mono",
+ "value": "{{_global.bz_tpl_modal?.content?.templateContent ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateContent": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.binding.variables_hint",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.variables ?? []}}",
+ "item_var": "v"
+ },
+ "props": {
+ "type": "button",
+ "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 hover:border-teal-400"
+ },
+ "text": "{{'#{' + (v.key ?? '') + '}'}}",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sirsoft-message_bizppurio.insertVariable",
+ "params": {
+ "variable": "{{v.key}}",
+ "target": "bz_template_content",
+ "stateTarget": "global",
+ "statePath": "bz_tpl_modal.content.templateContent"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex flex-wrap gap-1"
+ }
+ }
+ ],
+ "props": {
+ "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
+ },
+ "if": "{{(_global.bz_tpl_modal?.variables ?? []).length > 0}}"
+ },
+ {
+ "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.template.form.extra *"
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "rows": 2,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content?.templateExtra ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateExtra": "{{$event.target.value}}"
+ }
+ }
+ ]
+ }
+ ],
+ "if": "{{_global.bz_tpl_modal?.content?.templateMessageType === 'EX' || _global.bz_tpl_modal?.content?.templateMessageType === 'MI'}}"
+ },
+ {
+ "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.template.form.header"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_tpl_modal?.content.templateHeader ?? ''}}",
+ "maxLength": 16
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.templateHeader": "{{$event.target.value}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.form.header_hint",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500 mt-1"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.buttons"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",
+ "disabled": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).length >= 5}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{[...(_global.bz_tpl_modal?.content?.buttons ?? []), {name: '', linkType: 'WL'}]}}"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.button_add"
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.content?.buttons ?? []}}",
+ "item_var": "buttonsItem",
+ "index_var": "buttonsIdx"
+ },
+ "props": {
+ "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-40 text-xs",
+ "value": "{{buttonsItem.linkType ?? 'WL'}}",
+ "options": "{{['WL','AL','DS','BK','MD','AC','BC','BT','P1','P2','P3','TN','MP'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name",
+ "value": "{{buttonsItem.name ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_button",
+ "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).filter((b, i) => i !== buttonsIdx)}}"
+ }
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-trash-can",
+ "size": "sm"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex items-center gap-1.5"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo",
+ "value": "{{buttonsItem.linkMo ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc",
+ "value": "{{buttonsItem.linkPc ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and",
+ "value": "{{buttonsItem.linkAnd ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios",
+ "value": "{{buttonsItem.linkIos ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number",
+ "value": "{{buttonsItem.telNumber ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'TN'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id",
+ "value": "{{buttonsItem.pluginId ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.buttons": "{{(_global.bz_tpl_modal?.content?.buttons ?? []).map((b, i) => i === buttonsIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{buttonsItem.linkType === 'P1' || buttonsItem.linkType === 'P2' || buttonsItem.linkType === 'P3'}}"
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "space-y-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Label",
+ "props": {
+ "className": "text-secondary text-xs font-medium"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.quick_replies"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-xs font-medium px-2.5 py-1 rounded-md whitespace-nowrap flex-shrink-0 border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700",
+ "disabled": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).length >= 10}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{[...(_global.bz_tpl_modal?.content?.quickReplies ?? []), {name: '', linkType: 'WL'}]}}"
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.quick_reply_add"
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "iteration": {
+ "source": "{{_global.bz_tpl_modal?.content?.quickReplies ?? []}}",
+ "item_var": "quickRepliesItem",
+ "index_var": "quickRepliesIdx"
+ },
+ "props": {
+ "className": "p-3 rounded-lg border border-gray-200 dark:border-gray-700 space-y-2"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "composite",
+ "name": "Select",
+ "props": {
+ "className": "w-40 text-xs",
+ "value": "{{quickRepliesItem.linkType ?? 'WL'}}",
+ "options": "{{['WL','AL','BK','MD','BC','BT'].map(v => ({value: v, label: $t('sirsoft-message_bizppurio.templates.link_type.' + v)}))}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkType: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.button_name",
+ "value": "{{quickRepliesItem.name ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {name: $event.target.value}) : b)}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "aria-label": "$t:sirsoft-message_bizppurio.template.form.btn_remove_quick_reply",
+ "className": "p-1.5 rounded-md text-gray-400 hover:text-red-600 dark:hover:text-red-400"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).filter((b, i) => i !== quickRepliesIdx)}}"
+ }
+ }
+ ],
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-trash-can",
+ "size": "sm"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex items-center gap-1.5"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_mo",
+ "value": "{{quickRepliesItem.linkMo ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkMo: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_pc",
+ "value": "{{quickRepliesItem.linkPc ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkPc: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'WL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_and",
+ "value": "{{quickRepliesItem.linkAnd ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkAnd: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.link_ios",
+ "value": "{{quickRepliesItem.linkIos ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {linkIos: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'AL'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.tel_number",
+ "value": "{{quickRepliesItem.telNumber ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {telNumber: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'TN'}}"
+ },
+ {
+ "type": "basic",
+ "name": "Input",
+ "props": {
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100 text-xs",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.plugin_id",
+ "value": "{{quickRepliesItem.pluginId ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.content.quickReplies": "{{(_global.bz_tpl_modal?.content?.quickReplies ?? []).map((b, i) => i === quickRepliesIdx ? Object.assign({}, b, {pluginId: $event.target.value}) : b)}}"
+ }
+ }
+ ],
+ "if": "{{quickRepliesItem.linkType === 'P1' || quickRepliesItem.linkType === 'P2' || quickRepliesItem.linkType === 'P3'}}"
+ }
+ ],
+ "props": {
+ "className": "grid grid-cols-1 sm:grid-cols-2 gap-1.5"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "space-y-2"
+ }
+ },
+ {
+ "id": "bz_manage_request_comment",
+ "type": "basic",
+ "name": "Div",
+ "comment": "검수자 전달 의견 — kapi template/request 의 comment(≤500). 카카오 심사 가이드가 요구하는 변수 예시값 등을 검수자에게 전할 유일한 통로(제품 결정 2026-08-23 §18.7). 신청 시에만 전송되며 행에 저장되지 않는다.",
+ "children": [
+ {
+ "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.template.form.request_comment"
+ },
+ {
+ "type": "basic",
+ "name": "Span",
+ "text": "{{(_global.bz_tpl_modal?.request_comment ?? '').length}}/500",
+ "props": {
+ "className": "text-xs text-gray-400 dark:text-gray-500"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex-between"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "name": "bz_request_comment",
+ "rows": 3,
+ "maxLength": 500,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "placeholder": "$t:sirsoft-message_bizppurio.template.form.request_comment_placeholder",
+ "value": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.request_comment": "{{$event.target.value}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "props": {
+ "className": "text-xs text-gray-500 dark:text-gray-400 mt-1"
+ },
+ "text": "$t:sirsoft-message_bizppurio.template.form.request_comment_hint"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "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.bz_tpl_modal?.isSaving ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_template"
+ }
+ ],
+ "text": "$t:common.cancel"
+ },
+ {
+ "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.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates",
+ "params": {
+ "method": "POST",
+ "body": {
+ "alimtalk_enabled": true,
+ "content": "{{_global.bz_tpl_modal?.content}}",
+ "notification_type": "{{_global.bz_tpl_modal?.notification_type}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_template"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.btn_save",
+ "if": "{{!(_global.bz_tpl_modal?.id)}}"
+ },
+ {
+ "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.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "alimtalk_enabled": true,
+ "content": "{{_global.bz_tpl_modal?.content}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_template"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.btn_save",
+ "if": "{{_global.bz_tpl_modal?.id}}"
+ },
+ {
+ "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.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates",
+ "params": {
+ "method": "POST",
+ "body": {
+ "alimtalk_enabled": true,
+ "content": "{{_global.bz_tpl_modal?.content}}",
+ "notification_type": "{{_global.bz_tpl_modal?.notification_type}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{response.data?.template?.id}}/request",
+ "params": {
+ "method": "POST",
+ "body": {
+ "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_template"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}"
+ }
+ }
+ ]
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request",
+ "if": "{{!(_global.bz_tpl_modal?.id)}}"
+ },
+ {
+ "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.bz_tpl_modal?.isSaving ?? false) || (_global.bz_tpl_upload?.uploading ?? false)}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": true,
+ "bz_tpl_modal.error": null
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "alimtalk_enabled": true,
+ "content": "{{_global.bz_tpl_modal?.content}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_tpl_modal?.id}}/request",
+ "params": {
+ "method": "POST",
+ "body": {
+ "comment": "{{_global.bz_tpl_modal?.request_comment ?? ''}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.form.requested_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_template"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}"
+ }
+ }
+ ]
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_tpl_modal.isSaving": false,
+ "bz_tpl_modal.error": "{{error.errors?.bizppurio_message ?? error.message}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "if": "{{!(_global.bz_tpl_modal?.isSaving ?? false)}}"
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.form.btn_save_request",
+ "if": "{{_global.bz_tpl_modal?.id}}"
+ }
+ ],
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ }
+ }
+ ]
+ },
+ {
+ "id": "modal_bizppurio_sms",
+ "comment": "대체 SMS/SMS 단독 본문 편집 모달(#597 §4.1). 값은 _global.bz_sms_modal, 저장은 delivery upsert(행 없으면 draft 생성). #{var} 치환 규칙은 알림톡 본문과 동일.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.template.sms.modal_title|name={{_global.bz_sms_modal?.notification_name ?? ''}}",
+ "size": "md",
+ "closeOnOverlayClick": false
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.sms.hint",
+ "props": {
+ "className": "text-xs text-teal-700 dark:text-teal-300 p-3 rounded-lg bg-teal-50 dark:bg-teal-950 border border-teal-200 dark:border-teal-800"
+ }
+ },
+ {
+ "id": "bz_sms_lang_tabs",
+ "comment": "SMS 본문 언어 탭(§14.3). sms_body 는 로케일 맵이며 수신자 로케일로 발송된다 — 코어 알림 템플릿 편집 모달과 동일한 editLang 패턴.",
+ "type": "basic",
+ "name": "Div",
+ "props": {
+ "className": "flex border-b border-gray-200 dark:border-gray-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "iteration": {
+ "source": "{{$locales}}",
+ "item_var": "loc"
+ },
+ "props": {
+ "type": "button",
+ "className": "{{(_global.bz_sms_modal?.editLang ?? $locale) === loc ? 'border-b-2 border-teal-500 dark:border-teal-400 text-teal-600 dark:text-teal-400' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}} px-4 py-2 text-sm font-medium focus:outline-none"
+ },
+ "text": "{{_global.appConfig?.localeNames?.[loc] ?? loc}}",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.editLang": "{{loc}}"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Textarea",
+ "props": {
+ "name": "bz_sms_body",
+ "rows": 4,
+ "maxLength": 2000,
+ "className": "w-full px-3 py-2 text-sm bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-gray-100",
+ "value": "{{_global.bz_sms_modal?.body?.[_global.bz_sms_modal?.editLang ?? $locale] ?? ''}}"
+ },
+ "actions": [
+ {
+ "type": "change",
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.body": "{{Object.assign({}, _global.bz_sms_modal?.body, {[_global.bz_sms_modal?.editLang ?? $locale]: $event.target.value})}}"
+ }
+ }
+ ]
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.binding.variables_hint",
+ "props": {
+ "className": "text-xs text-gray-600 dark:text-gray-400 mb-1"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Button",
+ "iteration": {
+ "source": "{{_global.bz_sms_modal?.variables ?? []}}",
+ "item_var": "v"
+ },
+ "props": {
+ "type": "button",
+ "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 hover:border-teal-400"
+ },
+ "text": "{{'#{' + (v.key ?? '') + '}'}}",
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sirsoft-message_bizppurio.insertVariable",
+ "params": {
+ "variable": "{{v.key}}",
+ "target": "bz_sms_body",
+ "stateTarget": "global",
+ "statePath": "{{'bz_sms_modal.body.' + (_global.bz_sms_modal?.editLang ?? $locale)}}"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "flex flex-wrap gap-1"
+ }
+ }
+ ],
+ "props": {
+ "className": "rounded-lg bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-3"
+ },
+ "if": "{{(_global.bz_sms_modal?.variables ?? []).length > 0}}"
+ }
+ ],
+ "props": {
+ "className": "space-y-3"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "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.bz_sms_modal?.isSaving ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_sms"
+ }
+ ],
+ "text": "$t:common.cancel"
+ },
+ {
+ "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.bz_sms_modal?.isSaving ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.isSaving": true
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/delivery/{{_global.bz_sms_modal?.notification_type}}",
+ "params": {
+ "method": "PUT",
+ "body": {
+ "sms_body": "{{Object.assign({}, _global.bz_sms_modal?.body)}}"
+ }
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.sms.saved_toast",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_sms"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_sms_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.sms.btn_save"
+ }
+ ],
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ }
+ }
+ ]
+ },
+ {
+ "id": "modal_bizppurio_cancel_approval",
+ "comment": "승인 취소 경고 확인 모달(#597 §3.2). 확정 시 kapi cancel_approval → draft 복귀 — status 가 approved 를 벗어나는 즉시 발송 게이트가 차단된다(대체 SMS 는 sms 채널 규칙으로 계속). 취소 후 상세를 받아 작성 모달을 이어서 연다.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.template.cancel_approval.modal_title",
+ "size": "sm",
+ "closeOnOverlayClick": false
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Icon",
+ "props": {
+ "name": "fas fa-triangle-exclamation",
+ "className": "text-amber-500 flex-shrink-0"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.warning|name={{_global.bz_cancel_approval?.notification_name ?? ''}}",
+ "props": {
+ "className": "text-sm text-gray-700 dark:text-gray-200"
+ }
+ }
+ ],
+ "props": {
+ "className": "flex items-start gap-2"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "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.bz_cancel_approval?.isSaving ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_cancel_approval"
+ }
+ ],
+ "text": "$t:common.cancel"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md bg-amber-600 text-white hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 disabled:opacity-50",
+ "disabled": "{{_global.bz_cancel_approval?.isSaving ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_cancel_approval.isSaving": true
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_cancel_approval?.id}}/cancel-approval",
+ "params": {
+ "method": "POST"
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "$t:sirsoft-message_bizppurio.template.cancel_approval.done_toast",
+ "type": "info"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_cancel_approval"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_cancel_approval.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_cancel_approval.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.cancel_approval.btn_confirm"
+ }
+ ],
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ }
+ }
+ ]
+ },
+ {
+ "id": "modal_bizppurio_rejection",
+ "comment": "반려 사유 열람 모달(#597 §4.1). kapi detail 의 comments 스냅샷(inspection_detail) 원문을 그대로 보여준다.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.template.rejection.modal_title|name={{_global.bz_reject_view?.notification_name ?? ''}}",
+ "size": "md"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "children": [
+ {
+ "type": "basic",
+ "name": "Div",
+ "iteration": {
+ "source": "{{_global.bz_reject_view?.comments ?? []}}",
+ "item_var": "cm"
+ },
+ "props": {
+ "className": "p-3 rounded-lg bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700"
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "{{(cm.createdAt ?? '').slice(0, 16).split('T').join(' ')}}",
+ "props": {
+ "className": "text-xs text-red-500 dark:text-red-400"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "{{cm.content ?? ''}}",
+ "props": {
+ "className": "text-sm text-red-800 dark:text-red-200 whitespace-pre-wrap"
+ }
+ }
+ ]
+ }
+ ],
+ "props": {
+ "className": "space-y-2 max-h-80 overflow-y-auto"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "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"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_rejection"
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.template.rejection.btn_close"
+ }
+ ],
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ }
+ }
+ ]
+ },
+ {
+ "id": "modal_bizppurio_delete",
+ "comment": "템플릿 삭제 확인 모달(#597 §3.2 DELETE). 카카오측 코드는 삭제 가능 상태(R+REG/REJ)일 때만 동반 삭제되며, 그 외에는 DB 행만 삭제되고 사유가 응답에 실린다.",
+ "type": "composite",
+ "name": "Modal",
+ "props": {
+ "title": "$t:sirsoft-message_bizppurio.manage.delete.modal_title",
+ "size": "sm",
+ "closeOnOverlayClick": false
+ },
+ "children": [
+ {
+ "type": "basic",
+ "name": "P",
+ "text": "$t:sirsoft-message_bizppurio.manage.delete.warning|name={{_global.bz_delete_modal?.notification_name ?? ''}}",
+ "props": {
+ "className": "text-sm text-gray-700 dark:text-gray-200"
+ }
+ },
+ {
+ "type": "basic",
+ "name": "Div",
+ "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.bz_delete_modal?.isSaving ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "closeModal",
+ "target": "modal_bizppurio_delete"
+ }
+ ],
+ "text": "$t:common.cancel"
+ },
+ {
+ "type": "basic",
+ "name": "Button",
+ "props": {
+ "type": "button",
+ "className": "text-sm px-4 py-2 rounded-md bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 disabled:opacity-50",
+ "disabled": "{{_global.bz_delete_modal?.isSaving ?? false}}"
+ },
+ "actions": [
+ {
+ "type": "click",
+ "handler": "sequence",
+ "params": {
+ "actions": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_delete_modal.isSaving": true
+ }
+ },
+ {
+ "handler": "apiCall",
+ "auth_required": true,
+ "target": "/api/plugins/sirsoft-message_bizppurio/admin/templates/{{_global.bz_delete_modal?.id}}",
+ "params": {
+ "method": "DELETE"
+ },
+ "onSuccess": [
+ {
+ "handler": "refetchDataSource",
+ "params": {
+ "dataSourceId": "bizppurio_templates_list"
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{response.data?.kakao_deleted ? $t('sirsoft-message_bizppurio.manage.delete.done_with_kakao') : $t('sirsoft-message_bizppurio.manage.delete.done_db_only')}}",
+ "type": "success"
+ }
+ },
+ {
+ "handler": "closeModal",
+ "target": "modal_bizppurio_delete"
+ },
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_delete_modal.isSaving": false
+ }
+ }
+ ],
+ "onError": [
+ {
+ "handler": "setState",
+ "params": {
+ "target": "global",
+ "bz_delete_modal.isSaving": false
+ }
+ },
+ {
+ "handler": "toast",
+ "params": {
+ "message": "{{error.errors?.bizppurio_message ?? error.message}}",
+ "type": "error"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ ],
+ "text": "$t:sirsoft-message_bizppurio.manage.delete.btn_confirm"
+ }
+ ],
+ "props": {
+ "className": "flex justify-end gap-2 pt-4 mt-2 border-t border-gray-200 dark:border-gray-700"
+ }
+ }
+ ]
+ }
+ ]
+}
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..6864ddbd
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Concerns/GuardsKakaoRequests.php
@@ -0,0 +1,47 @@
+ $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/Console/SyncTemplateStatusCommand.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Console/SyncTemplateStatusCommand.php
new file mode 100644
index 00000000..8ab36e72
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Console/SyncTemplateStatusCommand.php
@@ -0,0 +1,60 @@
+syncRequested();
+
+ if ($result['checked'] === 0) {
+ $this->info('검수중(requested) 템플릿이 없어 카카오 조회를 건너뜁니다.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info(sprintf(
+ '검수중 템플릿 %d건 점검, %d건 상태 전이.',
+ $result['checked'],
+ $result['transitioned'],
+ ));
+
+ return self::SUCCESS;
+ }
+}
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..056d2a6f
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/AlimtalkTemplateController.php
@@ -0,0 +1,61 @@
+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(),
+ ]));
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php
new file mode 100644
index 00000000..85cf056b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Controllers/Admin/BizppurioTemplateController.php
@@ -0,0 +1,382 @@
+service->list($request->filters(), $request->page(), $request->perPage());
+
+ return ResponseHelper::success('messages.success', [
+ 'templates' => collect($paginator->items())
+ ->map(fn (BizppurioTemplate $row) => $this->listRow($row))
+ ->all(),
+ 'pagination' => [
+ 'total' => $paginator->total(),
+ 'last_page' => $paginator->lastPage(),
+ 'has_more_pages' => $paginator->hasMorePages(),
+ 'current_page' => $paginator->currentPage(),
+ 'per_page' => $paginator->perPage(),
+ ],
+ ]);
+ }
+
+ /**
+ * 알림 설정 탭 행 표시용 요약 맵을 조회합니다 (notification_type 키).
+ *
+ * 알림 설정 3면(코어/게시판/이커머스)의 행 하단 UI 가 소비한다. 행 수는 알림 정의
+ * 수에 묶인 설정성 데이터라 페이지네이션 없이 전량 내려준다.
+ *
+ * @return JsonResponse data.templates 에 notification_type 키 요약 맵
+ */
+ public function map(): JsonResponse
+ {
+ return ResponseHelper::success('messages.success', [
+ 'templates' => $this->service->summaryMap(),
+ ]);
+ }
+
+ /**
+ * 템플릿 상세를 조회합니다 (content·inspection_detail 포함).
+ *
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data.template 에 상세
+ */
+ public function show(int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return ResponseHelper::success('messages.success', [
+ 'template' => $this->detailRow($template),
+ ]);
+ }
+
+ /**
+ * 템플릿을 생성합니다 (status=draft).
+ *
+ * @param StoreBizppurioTemplateRequest $request 검증된 생성 데이터
+ * @return JsonResponse data.template 에 생성된 행
+ */
+ public function store(StoreBizppurioTemplateRequest $request): JsonResponse
+ {
+ $template = $this->service->create($request->validated());
+
+ return ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.created',
+ ['template' => $this->detailRow($template)],
+ 201,
+ );
+ }
+
+ /**
+ * 템플릿을 수정합니다 (content 변경은 draft/rejected 만).
+ *
+ * @param UpdateBizppurioTemplateRequest $request 검증된 수정 데이터
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data.template 에 갱신된 행
+ */
+ public function update(UpdateBizppurioTemplateRequest $request, int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return $this->guardLifecycle(fn () => ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.updated',
+ ['template' => $this->detailRow($this->service->update($template, $request->validated()))],
+ ));
+ }
+
+ /**
+ * 발송 설정(알림톡 사용·대체 SMS·SMS 본문·SMS 단독·활성)을 upsert 합니다.
+ *
+ * 알림 설정 탭 행 하단 토글의 즉시 저장 경로. 대상 행이 없으면 draft 로 생성한다.
+ *
+ * @param UpdateBizppurioDeliveryRequest $request 검증된 발송 설정
+ * @return JsonResponse data.template 에 저장된 행
+ */
+ public function upsertDelivery(UpdateBizppurioDeliveryRequest $request): JsonResponse
+ {
+ $template = $this->service->upsertDelivery(
+ (string) $request->route('notificationType'),
+ $request->deliveryData(),
+ );
+
+ return ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.updated',
+ ['template' => $this->detailRow($template)],
+ );
+ }
+
+ /**
+ * 검수를 신청합니다 (채번 → kapi add/update → request → status=requested).
+ *
+ * @param RequestBizppurioInspectionRequest $request 검수자 전달 의견(comment, 선택 ≤500)
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data.template 에 갱신된 행
+ */
+ public function requestInspection(RequestBizppurioInspectionRequest $request, int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return $this->guardLifecycle(fn () => ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.requested',
+ ['template' => $this->detailRow($this->service->requestInspection($template, $request->comment()))],
+ ));
+ }
+
+ /**
+ * 검수 신청을 취소합니다 (REQ→REG, status=draft 복귀).
+ *
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data.template 에 갱신된 행
+ */
+ public function cancelRequest(int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return $this->guardLifecycle(fn () => ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.request_cancelled',
+ ['template' => $this->detailRow($this->service->cancelRequest($template))],
+ ));
+ }
+
+ /**
+ * 승인을 취소합니다 (승인→등록 복귀 — 알림톡 발송 즉시 차단).
+ *
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data.template 에 갱신된 행
+ */
+ public function cancelApproval(int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return $this->guardLifecycle(fn () => ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.approval_cancelled',
+ ['template' => $this->detailRow($this->service->cancelApproval($template))],
+ ));
+ }
+
+ /**
+ * 휴면(DMT) 템플릿을 해제합니다.
+ *
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data.template 에 갱신된 행
+ */
+ public function release(int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return $this->guardLifecycle(fn () => ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.released',
+ ['template' => $this->detailRow($this->service->releaseDormant($template))],
+ ));
+ }
+
+ /**
+ * 단건 상태를 카카오와 동기화합니다 (수동 [새로고침]).
+ *
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data.template 에 갱신된 행
+ */
+ public function sync(int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return $this->guardLifecycle(fn () => ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.synced',
+ ['template' => $this->detailRow($this->service->sync($template))],
+ ));
+ }
+
+ /**
+ * 이미지형 템플릿용 이미지를 업로드합니다 (kapi 업로드 프록시).
+ *
+ * @param BizppurioTemplateImageRequest $request 검증된 이미지 파일
+ * @return JsonResponse data.url 에 카카오 이미지 URL
+ */
+ public function uploadImage(BizppurioTemplateImageRequest $request): JsonResponse
+ {
+ return $this->guardLifecycle(fn () => ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.image_uploaded',
+ ['url' => $this->service->uploadImage($request->file('image'))],
+ ));
+ }
+
+ /**
+ * 템플릿을 삭제합니다 (카카오측은 삭제 가능 상태일 때만 동반 삭제).
+ *
+ * @param int $id 템플릿 PK
+ * @return JsonResponse data 에 kakao_deleted·kakao_skip_reason
+ */
+ public function destroy(int $id): JsonResponse
+ {
+ $template = $this->service->find($id);
+ if ($template === null) {
+ return ResponseHelper::error('messages.not_found', 404);
+ }
+
+ return ResponseHelper::success(
+ 'sirsoft-message_bizppurio::messages.template.deleted',
+ $this->service->delete($template),
+ );
+ }
+
+ /**
+ * 라이프사이클 액션을 실행하고 도메인 실패를 422 로 매핑합니다.
+ *
+ * - BizppurioApiException: 카카오가 준 사유 원문(bizppurio_message)·결과코드를 errors 로 노출
+ * (관리자 전용 면 — 조치 근거 제공, TokenCheckController 규약과 동일)
+ * - BizppurioTemplateStateException: 예외가 든 메시지 키를 해석해 422
+ * 그 외 예외는 잡지 않는다 — 인프라 장애·코드 결함은 500 으로 드러나야 한다.
+ *
+ * @param callable():JsonResponse $callback 라이프사이클 액션
+ * @return JsonResponse 성공 응답 또는 422
+ */
+ private function guardLifecycle(callable $callback): JsonResponse
+ {
+ try {
+ return $callback();
+ } catch (BizppurioApiException $e) {
+ return ResponseHelper::error(
+ 'sirsoft-message_bizppurio::messages.error.kakao_request_failed',
+ 422,
+ [
+ 'bizppurio_message' => $e->getMessage(),
+ 'result_code' => $e->getResultCode(),
+ ],
+ );
+ } catch (BizppurioTemplateStateException $e) {
+ return ResponseHelper::error($e->getMessageKey(), 422, null, $e->getMessageParams());
+ }
+ }
+
+ /**
+ * 관리 화면 목록 행을 직렬화합니다 (조인된 정의 라벨·소속 포함, 대형 JSON 제외).
+ *
+ * @param BizppurioTemplate $row 목록 행 (definition_* 조인 별칭 포함)
+ * @return array 직렬화된 행
+ */
+ private function listRow(BizppurioTemplate $row): array
+ {
+ $definitionName = $row->getAttribute('definition_name');
+ $definitionVariables = $row->getAttribute('definition_variables');
+
+ return [
+ 'id' => $row->id,
+ 'notification_type' => $row->notification_type,
+ 'definition_name' => is_string($definitionName) ? (json_decode($definitionName, true) ?: null) : null,
+ 'definition_variables' => is_string($definitionVariables) ? (json_decode($definitionVariables, true) ?: []) : [],
+ 'definition_extension_type' => $row->getAttribute('definition_extension_type'),
+ 'definition_extension_identifier' => $row->getAttribute('definition_extension_identifier'),
+ 'alimtalk_enabled' => $row->alimtalk_enabled,
+ 'template_code' => $row->template_code,
+ 'status' => $row->status->value,
+ 'has_content' => (bool) $row->getAttribute('has_content'),
+ 'requested_at' => $row->requested_at?->toIso8601String(),
+ 'approved_at' => $row->approved_at?->toIso8601String(),
+ 'last_synced_at' => $row->last_synced_at?->toIso8601String(),
+ 'fallback_sms_enabled' => $row->fallback_sms_enabled,
+ 'sms_only' => $row->sms_only,
+ 'is_active' => $row->is_active,
+ ];
+ }
+
+ /**
+ * 템플릿 상세를 직렬화합니다 (content·승인 스냅샷·반려 사유 포함).
+ *
+ * @param BizppurioTemplate $template 대상 행
+ * @return array 직렬화된 상세
+ */
+ private function detailRow(BizppurioTemplate $template): array
+ {
+ return [
+ 'id' => $template->id,
+ 'notification_type' => $template->notification_type,
+ 'alimtalk_enabled' => $template->alimtalk_enabled,
+ 'template_code' => $template->template_code,
+ 'sender_key' => $template->sender_key,
+ 'content' => $template->content,
+ 'approved_content' => $template->approved_content,
+ 'status' => $template->status->value,
+ 'is_approved' => $template->status === BizppurioTemplateStatus::Approved,
+ 'inspection_detail' => $template->inspection_detail,
+ 'requested_at' => $template->requested_at?->toIso8601String(),
+ 'approved_at' => $template->approved_at?->toIso8601String(),
+ 'last_synced_at' => $template->last_synced_at?->toIso8601String(),
+ 'fallback_sms_enabled' => $template->fallback_sms_enabled,
+ 'sms_body' => $template->sms_body,
+ 'sms_only' => $template->sms_only,
+ 'is_active' => $template->is_active,
+ ];
+ }
+}
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/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/BizppurioTemplateStatus.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/BizppurioTemplateStatus.php
new file mode 100644
index 00000000..2992f16a
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Enums/BizppurioTemplateStatus.php
@@ -0,0 +1,110 @@
+ self::Draft,
+ 'REQ' => self::Requested,
+ 'REJ' => self::Rejected,
+ 'RDY', 'ACT' => self::Approved,
+ 'STP' => self::Stopped,
+ 'BLK' => self::Blocked,
+ 'DMT' => self::Dormant,
+ default => null,
+ };
+ }
+
+ /**
+ * kapi 상세 응답(status/inspectionStatus/block/dormant)에서 serviceStatus 를 유도합니다.
+ *
+ * 상세 조회(template/detail)는 serviceStatus 대신 status(S/A/R)+inspectionStatus
+ * (REG/REQ/REJ/APR)+block/dormant 를 내려주므로 목록과 동일한 어휘로 환원한다.
+ * serviceStatus 필드가 이미 있으면 그대로 사용한다.
+ *
+ * @param array $detail kapi 템플릿 상세(또는 목록) 행
+ * @return string serviceStatus 코드
+ */
+ public static function serviceStatusFromDetail(array $detail): string
+ {
+ if (! empty($detail['serviceStatus'])) {
+ return (string) $detail['serviceStatus'];
+ }
+
+ $inspection = (string) ($detail['inspectionStatus'] ?? '');
+ $status = (string) ($detail['status'] ?? '');
+ $block = (bool) ($detail['block'] ?? false);
+ $dormant = (bool) ($detail['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',
+ };
+ }
+
+ /**
+ * 발송 가능 상태(승인) 여부를 반환합니다.
+ *
+ * @return bool 승인 상태면 true
+ */
+ public function isApproved(): bool
+ {
+ return $this === self::Approved;
+ }
+
+ /**
+ * 카카오 등록 내용(content) 수정·삭제가 가능한 상태인지 반환합니다.
+ *
+ * kapi 는 status R + inspectionStatus REG/REJ 에서만 update/delete 를 허용한다(부록 A-4).
+ * 우리 상태로는 Draft(등록·검수취소·승인취소 복귀)와 Rejected(반려)가 이에 해당한다.
+ *
+ * @return bool 수정 가능 상태면 true
+ */
+ public function allowsContentEdit(): bool
+ {
+ return $this === self::Draft || $this === self::Rejected;
+ }
+}
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/BizppurioTemplateStateException.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioTemplateStateException.php
new file mode 100644
index 00000000..57a0d715
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Exceptions/BizppurioTemplateStateException.php
@@ -0,0 +1,49 @@
+ $replace 메시지 치환 파라미터
+ */
+ public function __construct(
+ private readonly string $messageKey,
+ private readonly array $replace = [],
+ ) {
+ parent::__construct(__($messageKey, $replace));
+ }
+
+ /**
+ * 다국어 메시지 키를 반환합니다.
+ *
+ * @return string 다국어 메시지 키
+ */
+ public function getMessageKey(): string
+ {
+ return $this->messageKey;
+ }
+
+ /**
+ * 메시지 치환 파라미터를 반환합니다.
+ *
+ * @return array 치환 파라미터
+ */
+ public function getMessageParams(): array
+ {
+ return $this->replace;
+ }
+}
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/BizppurioTemplateImageRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateImageRequest.php
new file mode 100644
index 00000000..bf446f06
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateImageRequest.php
@@ -0,0 +1,91 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'image' => [
+ 'required',
+ 'file',
+ 'mimes:jpg,jpeg,png',
+ 'max:'.self::MAX_KILOBYTES,
+ 'dimensions:min_width='.self::MIN_WIDTH,
+ ],
+ ];
+ }
+
+ /**
+ * 가로:세로 = 2:1 비율을 after 훅으로 검증합니다.
+ *
+ * @param Validator $validator 검증기
+ */
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(function (Validator $v) {
+ $file = $this->file('image');
+ if ($file === null || ! $file->isValid()) {
+ return;
+ }
+
+ $size = @getimagesize((string) $file->getRealPath());
+ if ($size === false) {
+ return;
+ }
+
+ [$width, $height] = $size;
+ if ($height <= 0 || abs(($width / $height) - 2.0) > 0.01) {
+ $v->errors()->add('image', __('sirsoft-message_bizppurio::messages.validation.image_ratio_invalid'));
+ }
+ });
+ }
+
+ /**
+ * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
+ *
+ * @return array 필드 경로 → 라벨
+ */
+ public function attributes(): array
+ {
+ $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
+
+ return [
+ 'image' => $label('image'),
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateListRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateListRequest.php
new file mode 100644
index 00000000..cfaa0577
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/BizppurioTemplateListRequest.php
@@ -0,0 +1,96 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'status' => ['nullable', Rule::enum(BizppurioTemplateStatus::class)],
+ 'search' => ['nullable', 'string', 'max:100'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:'.self::MAX_PER_PAGE],
+ ];
+ }
+
+ /**
+ * 저장소 필터 배열을 반환합니다.
+ *
+ * @return array status / search
+ */
+ public function filters(): array
+ {
+ return [
+ 'status' => $this->validated('status'),
+ 'search' => $this->validated('search'),
+ ];
+ }
+
+ /**
+ * 요청 페이지 번호를 반환합니다.
+ *
+ * @return int 페이지 번호 (기본 1)
+ */
+ public function page(): int
+ {
+ return max(1, (int) ($this->validated('page') ?? 1));
+ }
+
+ /**
+ * 요청 페이지 크기를 반환합니다.
+ *
+ * @return int 페이지 크기 (기본 20, 최대 100)
+ */
+ public function perPage(): int
+ {
+ return min(self::MAX_PER_PAGE, max(1, (int) ($this->validated('per_page') ?? self::DEFAULT_PER_PAGE)));
+ }
+
+ /**
+ * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
+ *
+ * @return array 필드 경로 → 라벨
+ */
+ public function attributes(): array
+ {
+ $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
+
+ return [
+ 'status' => $label('status'),
+ 'search' => $label('search'),
+ 'page' => $label('page'),
+ 'per_page' => $label('per_page'),
+ ];
+ }
+}
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/Concerns/ValidatesTemplateContent.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/Concerns/ValidatesTemplateContent.php
new file mode 100644
index 00000000..d5ef5471
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/Concerns/ValidatesTemplateContent.php
@@ -0,0 +1,366 @@
+ 필드 경로 → 라벨
+ */
+ private function contentAttributes(): array
+ {
+ $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
+
+ return [
+ 'content' => $label('content'),
+ 'content.templateName' => $label('template_name'),
+ 'content.templateMessageType' => $label('message_type'),
+ 'content.templateEmphasizeType' => $label('emphasize_type'),
+ 'content.templateContent' => $label('template_content'),
+ 'content.templatePreviewMessage' => $label('preview_message'),
+ 'content.categoryCode' => $label('category_code'),
+ 'content.securityFlag' => $label('security_flag'),
+ 'content.templateExtra' => $label('extra'),
+ 'content.templateTitle' => $label('title'),
+ 'content.templateSubtitle' => $label('subtitle'),
+ 'content.templateHeader' => $label('header'),
+ 'content.templateImageName' => $label('image_name'),
+ 'content.templateImageUrl' => $label('image_url'),
+ 'content.templateItem' => $label('item'),
+ 'content.templateItem.list' => $label('item_list'),
+ 'content.templateItem.list.*.title' => $label('item_title'),
+ 'content.templateItem.list.*.description' => $label('item_description'),
+ 'content.templateItem.summary' => $label('summary'),
+ 'content.templateItem.summary.title' => $label('summary_title'),
+ 'content.templateItem.summary.description' => $label('summary_description'),
+ 'content.templateItemHighlight' => $label('highlight'),
+ 'content.templateItemHighlight.title' => $label('highlight_title'),
+ 'content.templateItemHighlight.description' => $label('highlight_description'),
+ 'content.templateItemHighlight.imageUrl' => $label('highlight_image_url'),
+ 'content.templateRepresentLink' => $label('represent_link'),
+ 'content.templateRepresentLink.linkMo' => $label('link_mo'),
+ 'content.templateRepresentLink.linkPc' => $label('link_pc'),
+ 'content.templateRepresentLink.linkAnd' => $label('link_and'),
+ 'content.templateRepresentLink.linkIos' => $label('link_ios'),
+ 'content.buttons' => $label('buttons'),
+ 'content.buttons.*.name' => $label('button_name'),
+ 'content.buttons.*.linkType' => $label('button_link_type'),
+ 'content.buttons.*.linkMo' => $label('link_mo'),
+ 'content.buttons.*.linkPc' => $label('link_pc'),
+ 'content.buttons.*.linkAnd' => $label('link_and'),
+ 'content.buttons.*.linkIos' => $label('link_ios'),
+ 'content.buttons.*.telNumber' => $label('tel_number'),
+ 'content.buttons.*.pluginId' => $label('plugin_id'),
+ 'content.quickReplies' => $label('quick_replies'),
+ 'content.quickReplies.*.name' => $label('quick_reply_name'),
+ 'content.quickReplies.*.linkType' => $label('quick_reply_link_type'),
+ 'content.quickReplies.*.linkMo' => $label('link_mo'),
+ 'content.quickReplies.*.linkPc' => $label('link_pc'),
+ 'content.quickReplies.*.linkAnd' => $label('link_and'),
+ 'content.quickReplies.*.linkIos' => $label('link_ios'),
+ ];
+ }
+
+ /**
+ * SMS 본문(다국어 맵)의 검증 규칙을 반환합니다 (#597 §14.3).
+ *
+ * sms_body 는 로케일별 본문 맵이다. 허용 로케일 판정·길이 검사는 코어 TranslatableField
+ * 규칙에 위임한다 — 로케일 목록(`app.translatable_locales`)은 활성 언어팩에 따라 부팅마다
+ * 달라지는 가변값이고, 그 규칙이 이미 "비활성 언어팩의 기존 번역은 지우지 않는다" 는
+ * 판정까지 담고 있다. 여기서 다시 조립하면 같은 판정이 두 벌이 된다.
+ *
+ * Store/Update/Delivery 세 경로가 같은 규칙을 써야 한다 — 한 곳만 느슨하면 그 경로가
+ * 우회로가 된다.
+ *
+ * @return array> 필드 경로 → 규칙
+ */
+ private function smsBodyRules(): array
+ {
+ return [
+ 'sms_body' => ['sometimes', 'nullable', 'array', new TranslatableField(maxLength: 2000)],
+ ];
+ }
+
+ /**
+ * 발송 설정 필드의 표시용 라벨을 반환합니다 (FormRequest::attributes 병합용).
+ *
+ * @return array 필드 경로 → 라벨
+ */
+ private function deliveryAttributes(): array
+ {
+ $label = static fn (string $key): string => __("sirsoft-message_bizppurio::messages.validation.attributes.{$key}");
+
+ return [
+ 'notification_type' => $label('notification_type'),
+ 'alimtalk_enabled' => $label('alimtalk_enabled'),
+ 'fallback_sms_enabled' => $label('fallback_sms_enabled'),
+ 'sms_body' => $label('sms_body'),
+ 'sms_only' => $label('sms_only'),
+ 'is_active' => $label('is_active'),
+ ];
+ }
+
+ /**
+ * 유형과 무관한 content 필드를 잘라내 kapi 등록 페이로드를 정돈합니다.
+ *
+ * 화면(작성 모달)은 폼 상태 전체를 그대로 전송한다 — 유형 전환 잔여값(강조표기
+ * 타이틀·이미지 URL·아이템리스트 등)을 클라이언트가 조건부로 빼는 대신, 서버가
+ * 선택된 templateMessageType/templateEmphasizeType 기준으로 무관 필드·빈 값을
+ * 제거한다. 저장된 content 가 곧 kapi add/update 페이로드이므로(§3.1) 이 정돈이
+ * 등록 시 불필요 필드 거부를 예방한다. prepareForValidation 에서 호출한다.
+ */
+ private function pruneContentPayload(): void
+ {
+ $content = $this->input('content');
+ if (! is_array($content)) {
+ return;
+ }
+
+ $messageType = (string) ($content['templateMessageType'] ?? '');
+ $emphasizeType = (string) ($content['templateEmphasizeType'] ?? '');
+
+ if (! in_array($messageType, ['EX', 'MI'], true)) {
+ unset($content['templateExtra']);
+ }
+
+ if ($emphasizeType !== 'TEXT') {
+ unset($content['templateTitle'], $content['templateSubtitle']);
+ }
+
+ if ($emphasizeType !== 'IMAGE') {
+ unset($content['templateImageName'], $content['templateImageUrl']);
+ }
+
+ if ($emphasizeType !== 'ITEM_LIST') {
+ unset($content['templateItem'], $content['templateItemHighlight']);
+ }
+
+ // 빈 선택 값 제거 — 빈 문자열/빈 배열을 kapi 에 보내면 등록이 거부될 수 있다.
+ foreach (['templatePreviewMessage', 'templateHeader', 'templateExtra', 'templateTitle', 'templateSubtitle', 'templateImageName', 'templateImageUrl'] as $key) {
+ if (array_key_exists($key, $content) && trim((string) $content[$key]) === '') {
+ unset($content[$key]);
+ }
+ }
+
+ foreach (['buttons', 'quickReplies'] as $group) {
+ if (array_key_exists($group, $content)) {
+ $items = is_array($content[$group]) ? array_values(array_filter($content[$group], 'is_array')) : [];
+ $items = array_map($this->pruneEmptyStrings(...), $items);
+
+ if ($items === []) {
+ unset($content[$group]);
+ } else {
+ $content[$group] = $items;
+ }
+ }
+ }
+
+ if (array_key_exists('templateItemHighlight', $content)) {
+ $highlight = is_array($content['templateItemHighlight'])
+ ? $this->pruneEmptyStrings($content['templateItemHighlight'])
+ : [];
+ if ($highlight === []) {
+ unset($content['templateItemHighlight']);
+ } else {
+ $content['templateItemHighlight'] = $highlight;
+ }
+ }
+
+ if (array_key_exists('templateRepresentLink', $content)) {
+ $link = is_array($content['templateRepresentLink'])
+ ? $this->pruneEmptyStrings($content['templateRepresentLink'])
+ : [];
+ if ($link === []) {
+ unset($content['templateRepresentLink']);
+ } else {
+ $content['templateRepresentLink'] = $link;
+ }
+ }
+
+ if (array_key_exists('templateItem', $content) && is_array($content['templateItem'])) {
+ $item = $content['templateItem'];
+ if (isset($item['summary']) && is_array($item['summary'])) {
+ $summary = $this->pruneEmptyStrings($item['summary']);
+ if ($summary === []) {
+ unset($item['summary']);
+ } else {
+ $item['summary'] = $summary;
+ }
+ }
+ $content['templateItem'] = $item;
+ }
+
+ $this->merge(['content' => $content]);
+ }
+
+ /**
+ * 연관 배열에서 빈 문자열 값을 제거합니다.
+ *
+ * @param array $row 대상 배열
+ * @return array 빈 문자열이 제거된 배열
+ */
+ private function pruneEmptyStrings(array $row): array
+ {
+ return array_filter(
+ $row,
+ static fn ($value) => ! (is_string($value) && trim($value) === ''),
+ );
+ }
+
+ /**
+ * content 하위 검증 규칙을 반환합니다.
+ *
+ * @return array
+ */
+ private function contentRules(): array
+ {
+ return [
+ 'content' => ['sometimes', 'nullable', 'array'],
+ 'content.templateName' => ['required_with:content', 'string', 'max:200'],
+ 'content.templateMessageType' => ['required_with:content', 'in:BA,EX,AD,MI'],
+ 'content.templateEmphasizeType' => ['required_with:content', 'in:NONE,TEXT,IMAGE,ITEM_LIST'],
+ 'content.templateContent' => ['required_with:content', 'string', 'max:1000'],
+ 'content.templatePreviewMessage' => ['nullable', 'string', 'max:40'],
+ 'content.categoryCode' => ['required_with:content', 'string', 'max:20'],
+ 'content.securityFlag' => ['nullable', 'boolean'],
+ // EX(부가정보형)·MI(복합형)는 부가정보 필수 (A-2)
+ 'content.templateExtra' => ['nullable', 'string', 'required_if:content.templateMessageType,EX,MI'],
+ // TEXT(강조표기) 필수쌍 — 길이 수치는 문서 미기재라 미강제(kapi 위임)
+ 'content.templateTitle' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,TEXT'],
+ 'content.templateSubtitle' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,TEXT'],
+ 'content.templateHeader' => ['nullable', 'string', 'max:16'],
+ // IMAGE(이미지형) 필수쌍 — url 은 업로드 프록시 응답값만 화면이 기입한다
+ 'content.templateImageName' => ['nullable', 'string', 'required_if:content.templateEmphasizeType,IMAGE'],
+ 'content.templateImageUrl' => ['nullable', 'string', 'max:500', 'required_if:content.templateEmphasizeType,IMAGE'],
+ // ITEM_LIST(아이템리스트) — list 2~10개, 각 title≤6·description≤23 (A-2)
+ 'content.templateItem' => ['nullable', 'array', 'required_if:content.templateEmphasizeType,ITEM_LIST'],
+ 'content.templateItem.list' => ['nullable', 'array', 'min:2', 'max:10', 'required_with:content.templateItem'],
+ 'content.templateItem.list.*.title' => ['required', 'string', 'max:6'],
+ 'content.templateItem.list.*.description' => ['required', 'string', 'max:23'],
+ 'content.templateItem.summary' => ['nullable', 'array'],
+ 'content.templateItem.summary.title' => ['nullable', 'string', 'max:6'],
+ 'content.templateItem.summary.description' => ['nullable', 'string', 'max:14'],
+ 'content.templateItemHighlight' => ['nullable', 'array'],
+ 'content.templateItemHighlight.title' => ['nullable', 'string'],
+ 'content.templateItemHighlight.description' => ['nullable', 'string'],
+ 'content.templateItemHighlight.imageUrl' => ['nullable', 'string', 'max:500'],
+ 'content.templateRepresentLink' => ['nullable', 'array'],
+ 'content.templateRepresentLink.linkMo' => ['nullable', 'string', 'max:500'],
+ 'content.templateRepresentLink.linkPc' => ['nullable', 'string', 'max:500'],
+ 'content.templateRepresentLink.linkAnd' => ['nullable', 'string', 'max:500'],
+ 'content.templateRepresentLink.linkIos' => ['nullable', 'string', 'max:500'],
+ 'content.buttons' => ['nullable', 'array', 'max:5'],
+ 'content.buttons.*.name' => ['required', 'string', 'max:14'],
+ 'content.buttons.*.linkType' => ['required', 'string', 'in:'.implode(',', self::BUTTON_LINK_TYPES)],
+ 'content.buttons.*.linkMo' => ['nullable', 'string', 'max:500'],
+ 'content.buttons.*.linkPc' => ['nullable', 'string', 'max:500'],
+ 'content.buttons.*.linkAnd' => ['nullable', 'string', 'max:500'],
+ 'content.buttons.*.linkIos' => ['nullable', 'string', 'max:500'],
+ 'content.buttons.*.telNumber' => ['nullable', 'string', 'max:20'],
+ 'content.buttons.*.pluginId' => ['nullable', 'string', 'max:100'],
+ 'content.quickReplies' => ['nullable', 'array', 'max:10'],
+ 'content.quickReplies.*.name' => ['required', 'string'],
+ 'content.quickReplies.*.linkType' => ['required', 'string', 'in:'.implode(',', self::QUICK_REPLY_LINK_TYPES)],
+ 'content.quickReplies.*.linkMo' => ['nullable', 'string', 'max:500'],
+ 'content.quickReplies.*.linkPc' => ['nullable', 'string', 'max:500'],
+ 'content.quickReplies.*.linkAnd' => ['nullable', 'string', 'max:500'],
+ 'content.quickReplies.*.linkIos' => ['nullable', 'string', 'max:500'],
+ ];
+ }
+
+ /**
+ * 배열 항목별 조건부 필수(linkType 별 링크 필드)와 하이라이트 썸네일 연동 길이를 검증합니다.
+ *
+ * Laravel 룰 문법으로 표현하기 어려운 항목별 조건을 after 훅에서 판정한다:
+ * - WL → linkMo 필수 / AL → linkAnd+linkIos 둘 다 / TN → telNumber / P1~P3 → pluginId (A-3)
+ * - itemHighlight 는 썸네일(imageUrl) 유무에 따라 title≤30/21 · description≤19/13 (A-2)
+ *
+ * @param Validator $validator 검증기
+ */
+ private function validateContentConditionals(Validator $validator): void
+ {
+ $content = $this->input('content');
+ if (! is_array($content)) {
+ return;
+ }
+
+ foreach (['buttons', 'quickReplies'] as $group) {
+ $items = $content[$group] ?? null;
+ if (! is_array($items)) {
+ continue;
+ }
+
+ foreach ($items as $index => $item) {
+ if (! is_array($item)) {
+ continue;
+ }
+
+ $linkType = (string) ($item['linkType'] ?? '');
+ $path = "content.{$group}.{$index}";
+
+ if ($linkType === 'WL' && trim((string) ($item['linkMo'] ?? '')) === '') {
+ $validator->errors()->add("{$path}.linkMo", __('sirsoft-message_bizppurio::messages.validation.link_mo_required'));
+ }
+
+ if ($linkType === 'AL') {
+ if (trim((string) ($item['linkAnd'] ?? '')) === '') {
+ $validator->errors()->add("{$path}.linkAnd", __('sirsoft-message_bizppurio::messages.validation.link_and_required'));
+ }
+ if (trim((string) ($item['linkIos'] ?? '')) === '') {
+ $validator->errors()->add("{$path}.linkIos", __('sirsoft-message_bizppurio::messages.validation.link_ios_required'));
+ }
+ }
+
+ if ($linkType === 'TN' && trim((string) ($item['telNumber'] ?? '')) === '') {
+ $validator->errors()->add("{$path}.telNumber", __('sirsoft-message_bizppurio::messages.validation.tel_number_required'));
+ }
+
+ if (in_array($linkType, ['P1', 'P2', 'P3'], true) && trim((string) ($item['pluginId'] ?? '')) === '') {
+ $validator->errors()->add("{$path}.pluginId", __('sirsoft-message_bizppurio::messages.validation.plugin_id_required'));
+ }
+ }
+ }
+
+ $highlight = $content['templateItemHighlight'] ?? null;
+ if (is_array($highlight)) {
+ $hasThumbnail = trim((string) ($highlight['imageUrl'] ?? '')) !== '';
+ $titleMax = $hasThumbnail ? 21 : 30;
+ $descriptionMax = $hasThumbnail ? 13 : 19;
+
+ if (mb_strlen((string) ($highlight['title'] ?? '')) > $titleMax) {
+ $validator->errors()->add('content.templateItemHighlight.title', __('sirsoft-message_bizppurio::messages.validation.highlight_title_too_long', ['max' => $titleMax]));
+ }
+
+ if (mb_strlen((string) ($highlight['description'] ?? '')) > $descriptionMax) {
+ $validator->errors()->add('content.templateItemHighlight.description', __('sirsoft-message_bizppurio::messages.validation.highlight_description_too_long', ['max' => $descriptionMax]));
+ }
+ }
+ }
+}
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/RequestBizppurioInspectionRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php
new file mode 100644
index 00000000..cbbf9392
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/RequestBizppurioInspectionRequest.php
@@ -0,0 +1,63 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'comment' => ['sometimes', 'nullable', 'string', 'max:500'],
+ ];
+ }
+
+ /**
+ * 검수자 전달 의견을 반환합니다 (앞뒤 공백 제거, 비어 있으면 null).
+ *
+ * @return string|null 전달 의견
+ */
+ public function comment(): ?string
+ {
+ $comment = trim((string) ($this->validated()['comment'] ?? ''));
+
+ return $comment === '' ? null : $comment;
+ }
+
+ /**
+ * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
+ *
+ * @return array 필드 경로 → 라벨
+ */
+ public function attributes(): array
+ {
+ return [
+ 'comment' => __('sirsoft-message_bizppurio::messages.validation.attributes.comment'),
+ ];
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreBizppurioTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreBizppurioTemplateRequest.php
new file mode 100644
index 00000000..24206a14
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/StoreBizppurioTemplateRequest.php
@@ -0,0 +1,84 @@
+pruneContentPayload();
+ }
+
+ /**
+ * 검증 규칙을 반환합니다.
+ *
+ * @return array
+ */
+ public function rules(): array
+ {
+ return array_merge([
+ 'notification_type' => [
+ 'required',
+ 'string',
+ 'max:100',
+ Rule::exists(NotificationDefinition::class, 'type'),
+ Rule::unique(BizppurioTemplate::class, 'notification_type'),
+ ],
+ 'alimtalk_enabled' => ['sometimes', 'boolean'],
+ 'fallback_sms_enabled' => ['sometimes', 'boolean'],
+ 'sms_only' => ['sometimes', 'boolean'],
+ 'is_active' => ['sometimes', 'boolean'],
+ ], $this->smsBodyRules(), $this->contentRules());
+ }
+
+ /**
+ * 항목별 조건부 필수(linkType 별 링크 필드 등)를 after 훅으로 검증합니다.
+ *
+ * @param Validator $validator 검증기
+ */
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(fn (Validator $v) => $this->validateContentConditionals($v));
+ }
+
+ /**
+ * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
+ *
+ * @return array 필드 경로 → 라벨
+ */
+ public function attributes(): array
+ {
+ return array_merge($this->deliveryAttributes(), $this->contentAttributes());
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioDeliveryRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioDeliveryRequest.php
new file mode 100644
index 00000000..15ee2ece
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioDeliveryRequest.php
@@ -0,0 +1,89 @@
+merge([
+ 'notification_type' => (string) $this->route('notificationType'),
+ ]);
+ }
+
+ /**
+ * 검증 규칙을 반환합니다.
+ *
+ * @return array
+ */
+ public function rules(): array
+ {
+ return [
+ 'notification_type' => [
+ 'required',
+ 'string',
+ 'max:100',
+ Rule::exists(NotificationDefinition::class, 'type'),
+ ],
+ 'alimtalk_enabled' => ['sometimes', 'boolean'],
+ 'fallback_sms_enabled' => ['sometimes', 'boolean'],
+ 'sms_only' => ['sometimes', 'boolean'],
+ 'is_active' => ['sometimes', 'boolean'],
+ ] + $this->smsBodyRules();
+ }
+
+ /**
+ * 저장할 발송 설정 필드만 반환합니다 (notification_type 제외).
+ *
+ * @return array
+ */
+ public function deliveryData(): array
+ {
+ return collect($this->validated())
+ ->only(['alimtalk_enabled', 'fallback_sms_enabled', 'sms_body', 'sms_only', 'is_active'])
+ ->all();
+ }
+
+ /**
+ * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
+ *
+ * @return array 필드 경로 → 라벨
+ */
+ public function attributes(): array
+ {
+ // 공용 deliveryAttributes() 를 그대로 쓴다 — 인라인으로 다시 나열하면 이 클래스만
+ // notification_type 라벨을 빠뜨린 채 그 필드를 검증하게 된다(실제로 그랬다).
+ return $this->deliveryAttributes();
+ }
+}
diff --git a/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioTemplateRequest.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioTemplateRequest.php
new file mode 100644
index 00000000..32154c77
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Http/Requests/UpdateBizppurioTemplateRequest.php
@@ -0,0 +1,73 @@
+pruneContentPayload();
+ }
+
+ /**
+ * 검증 규칙을 반환합니다.
+ *
+ * @return array
+ */
+ public function rules(): array
+ {
+ return array_merge([
+ 'alimtalk_enabled' => ['sometimes', 'boolean'],
+ 'fallback_sms_enabled' => ['sometimes', 'boolean'],
+ 'sms_only' => ['sometimes', 'boolean'],
+ 'is_active' => ['sometimes', 'boolean'],
+ ], $this->smsBodyRules(), $this->contentRules());
+ }
+
+ /**
+ * 항목별 조건부 필수(linkType 별 링크 필드 등)를 after 훅으로 검증합니다.
+ *
+ * @param Validator $validator 검증기
+ */
+ public function withValidator(Validator $validator): void
+ {
+ $validator->after(fn (Validator $v) => $this->validateContentConditionals($v));
+ }
+
+ /**
+ * 검증 오류 문구에 쓰일 필드 라벨을 반환합니다.
+ *
+ * @return array 필드 경로 → 라벨
+ */
+ public function attributes(): array
+ {
+ return array_merge($this->deliveryAttributes(), $this->contentAttributes());
+ }
+}
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..02636300
--- /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..4e37402b
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/RegisterNotificationChannelsListener.php
@@ -0,0 +1,390 @@
+>
+ */
+ 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 채널 메타 정의를 반환합니다.
+ *
+ * '비즈뿌리오' 통합 탭 메타(#597 §3.3 — 범용 키, 확장명 하드코딩 없음):
+ * - sms.hidden_tab: 탭만 숨긴다(채널 토글 카드·발송 축은 그대로).
+ * - alimtalk.tab_channels: 이 목록 중 하나라도 활성 저장이면 탭을 노출한다.
+ * - alimtalk.tab_label_key: 탭 라벨용 프론트 lang 키($t 해석 — 카드 라벨(name)과 분리).
+ * - alimtalk.hidden_template_editor: 코어 알림 템플릿 [편집] 모달의 제목/본문 입력·미리보기·
+ * 코어 [저장]을 숨긴다(제품 결정 2026-08-23). 알림톡 본문은 카카오 규격(#{var}, 유형별 필드)이라
+ * 코어 본문과 다른 편집기가 필요하고, 그 편집기·통합 저장은 플러그인이 같은 모달의
+ * extension_point(notification_template_form_sections / _footer_actions)에 주입한다.
+ * 코어 채널 템플릿 행(제목/본문)은 발송에 쓰이지 않으므로 숨겨도 동작 손실이 없다.
+ * 코어 getAvailableChannels 는 임의 필드를 보존하므로 프론트까지 그대로 도달한다
+ * (is_test_mode 와 동일 경로).
+ *
+ * @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,
+ 'hidden_tab' => 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,
+ 'tab_channels' => ['sms', 'alimtalk'],
+ 'tab_label_key' => self::PLUGIN_IDENTIFIER.'.channels.bizppurio_tab',
+ 'hidden_template_editor' => 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..62ebab88
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Listeners/SeedChannelTemplatesListener.php
@@ -0,0 +1,274 @@
+ [...], ...]` (키가 type)
+ * - 모듈·기본값 복원 경로: 순차 배열 `[['type' => 'order_confirmed', ...], ...]`
+ * 둘 다 각 정의 값에 대해 동일하게 채널을 증강하고 키/순서는 보존한다.
+ *
+ * 증강 대상 = 회원(사용자) 대상 알림(결정 E). 관리자 전용(수신자가 role:admin 뿐인) 알림은
+ * 문자/알림톡 대상이 아니므로 건너뛴다. 기본 body 는 그 알림의 database 채널 body(짧은 평문)를
+ * 재활용하고, 없으면 mail body 의 HTML 을 제거해 만든다.
+ *
+ * #597 이후에도 이 시딩은 존치한다. 발송 본문의 SSoT 는 bizppurio_templates 로 옮겨졌지만
+ * (SmsChannelDriver 는 sms_body, AlimtalkChannelDriver 는 approved_content 를 읽는다),
+ * 코어·게시판·이커머스의 알림 설정 화면이 여전히 이 template 행을 그리기 때문이다
+ * (채널 서브탭의 행 제목·수신자 표시, 행이 없으면 "no_template_for_channel" 문구).
+ * 즉 이 행들은 화면 표시 전용이며 발송 경로는 소비하지 않는다 — 발송이 안 쓴다는 이유로
+ * 시딩을 지우면 알림 설정 화면의 비즈뿌리오 탭에서 행 정보가 사라진다.
+ */
+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',
+ 'core.notification.filter_default_definitions',
+ ] 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/BizppurioTemplate.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioTemplate.php
new file mode 100644
index 00000000..92e76913
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Models/BizppurioTemplate.php
@@ -0,0 +1,168 @@
+
+ */
+ protected $fillable = [
+ 'notification_type',
+ 'alimtalk_enabled',
+ 'template_code',
+ 'sender_key',
+ 'content',
+ 'approved_content',
+ 'status',
+ 'inspection_detail',
+ 'requested_at',
+ 'approved_at',
+ 'last_synced_at',
+ 'fallback_sms_enabled',
+ 'sms_body',
+ 'sms_only',
+ 'is_active',
+ ];
+
+ /**
+ * 속성 캐스팅 정의
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'alimtalk_enabled' => 'boolean',
+ 'content' => 'array',
+ 'approved_content' => 'array',
+ 'status' => BizppurioTemplateStatus::class,
+ 'inspection_detail' => 'array',
+ 'sms_body' => 'array',
+ 'requested_at' => 'datetime',
+ 'approved_at' => 'datetime',
+ 'last_synced_at' => 'datetime',
+ 'fallback_sms_enabled' => 'boolean',
+ 'sms_only' => 'boolean',
+ 'is_active' => 'boolean',
+ ];
+ }
+
+ /**
+ * 알림 유형으로 조회하는 스코프 (발송 시 행 해석).
+ *
+ * @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);
+ }
+
+ /**
+ * 수신자 로케일의 SMS 본문을 반환합니다 (#597 §14.3).
+ *
+ * sms_body 는 다국어 맵이다. 코어 NotificationContentBehavior::getLocalizedBody() 와
+ * 동일한 해석 규칙을 쓴다 — 해당 로케일이 없으면 fallback_locale, 그것도 없으면 빈 문자열.
+ * 알림톡 content 가 단일 언어인 것은 카카오 제약 때문이며 SMS 에는 그 제약이 없다.
+ *
+ * @param string|null $locale 수신자 로케일 (null 이면 현재 앱 로케일)
+ * @return string 로케일에 맞는 본문 (없으면 빈 문자열)
+ */
+ public function getLocalizedSmsBody(?string $locale = null): string
+ {
+ $locale = $locale ?? app()->getLocale();
+ $body = is_array($this->sms_body) ? $this->sms_body : [];
+
+ $resolved = $body[$locale] ?? $body[config('app.fallback_locale', 'ko')] ?? '';
+
+ return is_string($resolved) ? $resolved : '';
+ }
+
+ /**
+ * SMS 본문이 어느 로케일에든 채워져 있는지 판정합니다 (발송 게이트용).
+ *
+ * 게이트는 "이 알림에 문자 본문이 설정되어 있는가" 를 묻는다. 특정 수신자의 로케일에
+ * 본문이 없더라도 fallback 으로 발송되므로, 한 로케일이라도 비어있지 않으면 true 다.
+ * 로케일별 공백 판정은 발송 시점에 getLocalizedSmsBody() 로 다시 한다.
+ *
+ * @return bool 하나 이상의 로케일에 본문이 있으면 true
+ */
+ public function hasSmsBody(): bool
+ {
+ foreach (is_array($this->sms_body) ? $this->sms_body : [] as $text) {
+ if (is_string($text) && trim($text) !== '') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 알림톡 발송 가능 여부를 판정합니다 (발송 게이트 §3.5).
+ *
+ * alimtalk_enabled + is_active + 승인 상태 + 승인 스냅샷 존재를 모두 요구하고,
+ * sms_only("SMS 단독")가 켜져 있으면 차단한다.
+ * 승인 취소로 status 가 draft 로 복귀하면 approved_content 가 남아 있어도 즉시 차단된다.
+ *
+ * sms_only 는 이 알림에서 알림톡을 쓰지 않겠다는 운영자의 명시 선언이다(§3.5). 화면 라벨이
+ * "SMS 단독" 인 이상 이 판정에 반영되지 않으면 체크가 아무것도 바꾸지 못한다. 반대로 SMS
+ * 발송은 sms_only 와 무관하게 sms_body 기준으로만 판정한다(SmsChannelDriver) — sms_only 는
+ * 알림톡을 끄는 플래그지 SMS 를 켜는 플래그가 아니다.
+ *
+ * @return bool 알림톡 발송 가능하면 true
+ */
+ public function isAlimtalkSendable(): bool
+ {
+ return $this->alimtalk_enabled
+ && $this->is_active
+ && ! $this->sms_only
+ && $this->status->isApproved()
+ && is_array($this->approved_content)
+ && $this->approved_content !== [];
+ }
+}
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..3dc403c8
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Providers/MessageBizppurioServiceProvider.php
@@ -0,0 +1,139 @@
+
+ */
+ protected array $repositories = [
+ BizppurioDispatchRepositoryInterface::class => BizppurioDispatchRepository::class,
+ BizppurioTemplateRepositoryInterface::class => BizppurioTemplateRepository::class,
+ ];
+
+ /**
+ * CacheInterface 가 필요한 서비스 (contextual binding).
+ *
+ * - BizppurioTokenService(Phase 2): 발송 토큰 캐시
+ * - WebhookReportService(Phase 4): 잔액부족 알림 쿨다운(D3 중복 방지)
+ *
+ * @var array
+ */
+ protected array $cacheServices = [
+ BizppurioTokenService::class,
+ WebhookReportService::class,
+ ];
+
+ /**
+ * 서비스 컨테이너 바인딩을 등록합니다.
+ *
+ * DispatchLinkContext 는 한 발송 사이클(HTTP 요청/큐 잡) 안에서 refkey↔코어 로그 연결을
+ * 잇기 위해 상태를 공유해야 하므로 scoped(요청 단위 싱글턴)로 바인딩한다. 채널 드라이버와
+ * LinkNotificationLogListener 가 같은 인스턴스를 주입받아 refkey 를 주고받는다(A-2).
+ *
+ * 콘솔 커맨드(bizppurio:sync-template-status)는 plugin.php::getSchedules() 가 30분
+ * 주기로 스케줄한다(#597 §3.4).
+ */
+ public function register(): void
+ {
+ parent::register();
+
+ $this->app->scoped(DispatchLinkContext::class);
+
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ SyncTemplateStatusCommand::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/BizppurioTemplateRepository.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioTemplateRepository.php
new file mode 100644
index 00000000..aa150d52
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/BizppurioTemplateRepository.php
@@ -0,0 +1,246 @@
+
+ */
+ private const LIST_COLUMNS = [
+ 'id',
+ 'notification_type',
+ 'alimtalk_enabled',
+ 'template_code',
+ 'status',
+ 'requested_at',
+ 'approved_at',
+ 'last_synced_at',
+ 'fallback_sms_enabled',
+ 'sms_only',
+ 'is_active',
+ ];
+
+ /**
+ * PK 로 템플릿을 조회합니다.
+ *
+ * @param int $id 템플릿 PK
+ * @return BizppurioTemplate|null 매칭 행 또는 null
+ */
+ public function find(int $id): ?BizppurioTemplate
+ {
+ return BizppurioTemplate::query()->find($id);
+ }
+
+ /**
+ * 알림 유형으로 템플릿을 조회합니다.
+ *
+ * @param string $notificationType 코어 notification_definitions.type
+ * @return BizppurioTemplate|null 매칭 행 또는 null
+ */
+ public function findByType(string $notificationType): ?BizppurioTemplate
+ {
+ return BizppurioTemplate::query()
+ ->byNotificationType($notificationType)
+ ->first();
+ }
+
+ /**
+ * 관리 화면 목록을 페이지네이션 조회합니다 (알림 정의 라벨·소속 조인).
+ *
+ * 알림 정의(1:1, type unique)와 LEFT JOIN 해 목록이 그리는 라벨(definition_name)과
+ * 소속(definition_source)을 함께 싣는다. 1:1 조인이므로 행 부풀림이 없다. 정렬은
+ * 비고유 컬럼(updated_at) 뒤에 기본키를 덧붙여 전순서를 보장한다.
+ *
+ * @param array $filters status / search
+ * @param int $page 페이지 번호
+ * @param int $perPage 페이지 크기
+ * @return LengthAwarePaginator 페이지네이션 결과
+ */
+ public function paginateWithDefinitions(array $filters, int $page, int $perPage): LengthAwarePaginator
+ {
+ $templates = (new BizppurioTemplate)->getTable();
+ $definitions = (new NotificationDefinition)->getTable();
+
+ $query = BizppurioTemplate::query()
+ ->leftJoin($definitions.' as nd', 'nd.type', '=', $templates.'.notification_type')
+ ->select(array_map(
+ static fn (string $column): string => $templates.'.'.$column,
+ self::LIST_COLUMNS,
+ ))
+ ->addSelect([
+ 'nd.name as definition_name',
+ 'nd.variables as definition_variables',
+ 'nd.extension_type as definition_extension_type',
+ 'nd.extension_identifier as definition_extension_identifier',
+ ])
+ // 목록 배지가 "미작성 vs 작성중"을 구분하도록 content 존재 플래그만 계산해 싣는다.
+ // raw 안의 테이블명에는 빌더가 프리픽스를 붙여주지 않으므로 직접 부착한다.
+ ->selectRaw('('.DB::getTablePrefix().$templates.'.content is not null) as has_content');
+
+ if (! empty($filters['status'])) {
+ $query->where($templates.'.status', (string) $filters['status']);
+ }
+
+ if (! empty($filters['search'])) {
+ $term = '%'.addcslashes((string) $filters['search'], '%_\\').'%';
+ // nd.name 은 translatable JSON 컬럼 — raw LIKE 는 유니코드 이스케이프 저장값과
+ // 비교되어 비ASCII(한글 등) 검색이 항상 0건이 된다. 운영자가 화면에서 보는
+ // 표시명으로 검색되도록 로케일별 값을 추출해 비교한다 (선례:
+ // sirsoft-ecommerce ProductInquiryRepository 의 product_name_snapshot 검색).
+ // 로케일 키는 config 유래(사용자 입력 아님)이고 검색어는 바인딩으로 전달된다.
+ $locales = config('app.translatable_locales', ['ko', 'en']);
+ // raw 안의 조인 별칭에는 빌더가 프리픽스를 붙여주지 않으므로 직접 부착한다
+ // (조인 선언의 'as nd' 는 실제로 '{prefix}nd' 별칭이 된다).
+ $ndName = DB::getTablePrefix().'nd.name';
+ $query->where(function ($q) use ($templates, $term, $locales, $ndName) {
+ $q->where($templates.'.notification_type', 'like', $term);
+ foreach ($locales as $locale) {
+ $q->orWhereRaw(
+ "JSON_UNQUOTE(JSON_EXTRACT({$ndName}, '$.{$locale}')) LIKE ?",
+ [$term],
+ );
+ }
+ });
+ }
+
+ return $query
+ ->orderByDesc($templates.'.updated_at')
+ // audit:allow repository-pagination-key-tiebreak reason: 아래 기본키 tiebreak 이 전순서를 보장한다 —
+ // 조인 모호성 회피를 위해 테이블 한정 식별자(변수 결합)를 쓰므로 룰 정규식이 인식하지 못할 뿐이다
+ ->orderByDesc($templates.'.id')
+ // audit:allow repository-paginate-column-pruning reason: 위 select()/addSelect() 가 목록 소비 컬럼만
+ // 명시 조회한다 — paginate 인자의 컬럼 기본값은 기존 select 를 덮지 않는다
+ ->paginate($perPage, page: $page);
+ }
+
+ /**
+ * 알림 설정 탭 행 표시용 전체 요약을 조회합니다.
+ *
+ * 행 수는 알림 정의 수에 묶인 설정성 테이블이라 전량 조회한다(페이지네이션 규정의
+ * 설정성 테이블 예외). 요약에 불필요한 대형 JSON(content/approved_content)은 제외하되,
+ * 반려 사유(inspection_detail)는 행 UI 의 [사유 보기]가 소비하므로 포함한다.
+ *
+ * 행 수는 알림 정의 수(notification_type UNIQUE)에 묶인다 — 코어·확장이 선언한 알림
+ * 개수만큼만 존재하는 설정성 테이블이므로 상한 없는 get() 이 데이터 증가에 비례하지 않는다.
+ *
+ * @return Collection 요약 컬럼만 실린 행 컬렉션
+ */
+ public function allSummaries(): Collection
+ {
+ return BizppurioTemplate::query()
+ ->select(array_merge(self::LIST_COLUMNS, ['inspection_detail', 'sms_body']))
+ // content 원본은 요약에 불필요하지만 "작성 여부"(미작성 vs 작성중 배지 분기)는
+ // 행 UI 가 소비하므로 존재 플래그만 계산해 싣는다.
+ ->selectRaw('(content is not null) as has_content')
+ ->orderBy('notification_type')
+ ->get();
+ }
+
+ /**
+ * 특정 상태의 행 전체를 조회합니다 (동기화 커맨드 대상 선별).
+ *
+ * allSummaries() 와 같은 근거로 상한을 두지 않는다 — 알림 정의 수에 묶인 설정성 테이블의
+ * 부분집합이다.
+ *
+ * @param string $status BizppurioTemplateStatus value
+ * @return Collection
+ */
+ public function allByStatus(string $status): Collection
+ {
+ return BizppurioTemplate::query()
+ ->where('status', $status)
+ ->orderBy('id')
+ ->get();
+ }
+
+ /**
+ * 템플릿 행을 생성합니다.
+ *
+ * @param array $data 생성 데이터
+ * @return BizppurioTemplate 생성된 행
+ */
+ public function create(array $data): BizppurioTemplate
+ {
+ return BizppurioTemplate::create($data);
+ }
+
+ /**
+ * 템플릿 행을 갱신합니다.
+ *
+ * @param BizppurioTemplate $template 대상 행
+ * @param array $data 갱신 데이터
+ * @return BizppurioTemplate 갱신된 행
+ */
+ public function update(BizppurioTemplate $template, array $data): BizppurioTemplate
+ {
+ $template->fill($data)->save();
+
+ return $template;
+ }
+
+ /**
+ * 템플릿 행을 삭제합니다.
+ *
+ * @param BizppurioTemplate $template 대상 행
+ */
+ public function delete(BizppurioTemplate $template): void
+ {
+ $template->delete();
+ }
+
+ /**
+ * 특정 템플릿 코드가 이미 사용 중인지 확인합니다 (자체 채번 충돌 방지).
+ *
+ * @param string $templateCode 검사할 코드
+ * @return bool 사용 중이면 true
+ */
+ public function templateCodeExists(string $templateCode): bool
+ {
+ return BizppurioTemplate::query()
+ ->where('template_code', $templateCode)
+ ->exists();
+ }
+
+ /**
+ * 검수 신청을 위해 행을 원자적으로 선점합니다.
+ *
+ * 신청 가능 상태(draft/rejected)를 WHERE 조건에 포함한 조건부 UPDATE 라
+ * 같은 행을 동시에 든 요청 중 정확히 하나만 1행 갱신을 받는다.
+ *
+ * @param BizppurioTemplate $template 대상 행
+ * @return bool 선점 성공 여부
+ */
+ public function claimForInspection(BizppurioTemplate $template): bool
+ {
+ $claimed = BizppurioTemplate::query()
+ ->whereKey($template->id)
+ ->whereIn('status', [
+ BizppurioTemplateStatus::Draft->value,
+ BizppurioTemplateStatus::Rejected->value,
+ ])
+ ->update(['status' => BizppurioTemplateStatus::Requested->value]) === 1;
+
+ if ($claimed) {
+ $template->refresh();
+ }
+
+ return $claimed;
+ }
+}
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/BizppurioTemplateRepositoryInterface.php b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioTemplateRepositoryInterface.php
new file mode 100644
index 00000000..f92512a2
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Repositories/Contracts/BizppurioTemplateRepositoryInterface.php
@@ -0,0 +1,106 @@
+ $filters status / search
+ * @param int $page 페이지 번호
+ * @param int $perPage 페이지 크기
+ * @return LengthAwarePaginator 페이지네이션 결과
+ */
+ public function paginateWithDefinitions(array $filters, int $page, int $perPage): LengthAwarePaginator;
+
+ /**
+ * 알림 설정 탭 행 표시용 전체 요약을 조회합니다 (notification_type 키 맵 소스).
+ *
+ * 행 수는 운영자가 등록한 알림 정의 수에 묶인 설정성 테이블이므로 상한 없이 전량
+ * 조회한다(페이지네이션 규정의 설정성 테이블 예외). content 등 대형 JSON 컬럼은
+ * 요약에 필요 없으므로 제외한다(컬럼 프루닝).
+ *
+ * @return Collection 요약 컬럼만 실린 행 컬렉션
+ */
+ public function allSummaries(): Collection;
+
+ /**
+ * 특정 상태의 행 전체를 조회합니다 (동기화 커맨드 대상 선별).
+ *
+ * 대상 행 수는 알림 정의 수에 묶인다(설정성 테이블 예외 — 상한 불요).
+ *
+ * @param string $status BizppurioTemplateStatus value
+ * @return Collection
+ */
+ public function allByStatus(string $status): Collection;
+
+ /**
+ * 템플릿 행을 생성합니다.
+ *
+ * @param array $data 생성 데이터
+ * @return BizppurioTemplate 생성된 행
+ */
+ public function create(array $data): BizppurioTemplate;
+
+ /**
+ * 템플릿 행을 갱신합니다.
+ *
+ * @param BizppurioTemplate $template 대상 행
+ * @param array $data 갱신 데이터
+ * @return BizppurioTemplate 갱신된 행
+ */
+ public function update(BizppurioTemplate $template, array $data): BizppurioTemplate;
+
+ /**
+ * 템플릿 행을 삭제합니다.
+ *
+ * @param BizppurioTemplate $template 대상 행
+ */
+ public function delete(BizppurioTemplate $template): void;
+
+ /**
+ * 특정 템플릿 코드가 이미 사용 중인지 확인합니다 (자체 채번 충돌 방지).
+ *
+ * @param string $templateCode 검사할 코드
+ * @return bool 사용 중이면 true
+ */
+ public function templateCodeExists(string $templateCode): bool;
+
+ /**
+ * 검수 신청을 위해 행을 원자적으로 선점합니다.
+ *
+ * `status IN (draft, rejected)` 인 행만 조건부 UPDATE 로 requested 로 전이한다.
+ * 더블 클릭·이중 탭·복수 관리자처럼 같은 draft 상태를 동시에 든 요청 중
+ * 정확히 하나만 true 를 받는다 — 경합의 패자는 kapi 에 도달하기 전에 걸러진다.
+ *
+ * @param BizppurioTemplate $template 대상 행
+ * @return bool 선점 성공(이 요청이 신청을 진행) 여부
+ */
+ public function claimForInspection(BizppurioTemplate $template): bool;
+}
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..682a92e0
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkChannelDriver.php
@@ -0,0 +1,281 @@
+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. 템플릿 행 조회 + 발송 게이트 판정 (미승인·비활성·행 없음 → skip).
+ // 코어 NotificationDispatcher::sendToNotifiable()의 catch(\Exception)가 이 예외를
+ // channel_send_failed 훅으로 연결해, 발송 이력에 "성공"이 아닌 "실패"로 기록되게 한다.
+ $template = $this->templates->findByType($type);
+ if ($template === null || ! $template->isAlimtalkSendable()) {
+ throw new NotificationSendSkippedException(
+ __('sirsoft-message_bizppurio::messages.send_skipped.alimtalk_template_not_approved', ['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. 승인 스냅샷 → 발송 형식 변환 + 변수(#{var}) 치환. 본문이 비면 발송 불가라 skip.
+ $mapped = $this->payloadMapper->map((array) $template->approved_content, $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)])
+ );
+ }
+
+ // 4. refkey 생성 → payload 조립 (버튼·바로연결·요소는 extra, 대체발송 ON 시 SMS resend 병합)
+ $refkey = $this->generateRefkey();
+ $payload = $this->payloadBuilder->buildAlimtalk(
+ $to,
+ (string) $template->template_code,
+ $message,
+ $refkey,
+ (array) ($mapped['extra'] ?? []),
+ );
+
+ if ($template->fallback_sms_enabled) {
+ $payload = $this->withSmsFallback($payload, $this->smsFallbackBody(
+ $template,
+ $notification,
+ BaseNotification::resolveNotifiableLocale($notifiable),
+ ));
+ }
+
+ // 5. 발송 이력 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 대체발송 본문을 행의 sms_body 에서 렌더합니다 (#597 §3.5).
+ *
+ * 대체 SMS 본문 소스는 코어 알림 템플릿이 아니라 bizppurio_templates.sms_body 다.
+ * `#{var}` 표기를 알림 data 로 치환하며, 본문이 비어 있으면 빈 문자열을 반환하고
+ * withSmsFallback 이 병합하지 않는다(빈 SMS 방지).
+ *
+ * sms_body 는 로케일별 맵이므로 SmsChannelDriver 와 동일하게 수신자 로케일로 렌더한다 —
+ * 같은 알림의 SMS 단독 발송과 대체발송이 서로 다른 언어로 나가면 안 된다(§14.3).
+ *
+ * @param BizppurioTemplate $template 템플릿 행
+ * @param GenericNotification $notification 발송 대상 알림
+ * @param string|null $locale 수신자 로케일 (null 이면 현재 앱 로케일)
+ * @return string 치환 완료된 대체 SMS 본문 (없으면 빈 문자열)
+ */
+ private function smsFallbackBody(object $template, GenericNotification $notification, ?string $locale = null): string
+ {
+ $body = trim($template->getLocalizedSmsBody($locale));
+ if ($body === '') {
+ return '';
+ }
+
+ return $this->payloadMapper->substituteText($body, $notification->getData());
+ }
+
+ /**
+ * 알림톡 payload 에 SMS 대체발송(resend/recontent)을 병합합니다 (개별 대체발송).
+ *
+ * 알림톡 실패 시(수신 거부·미가입 등) 비즈뿌리오가 SMS 로 대체 발송한다.
+ * `resend:{first:"sms"}` + `recontent:{sms:{message}}` 구조를 따른다(현행 방식 유지 —
+ * G7 레벨 재발송 없음). 대체 본문이 비어 있으면 빈 SMS 를 보내지 않도록 병합하지 않는다.
+ *
+ * @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..6f9e96bf
--- /dev/null
+++ b/plugins/_bundled/sirsoft-message_bizppurio/src/Services/AlimtalkPayloadMapper.php
@@ -0,0 +1,312 @@
+
+ */
+ 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 로 치환합니다 (공개 진입점).
+ *
+ * 대체 SMS·SMS 단독 본문(bizppurio_templates.sms_body)이 알림톡 본문과 동일한
+ * `#{var}` 표기를 쓰므로(#597 §3.1), 드라이버가 본문 치환에 재사용한다.
+ *
+ * @param string $text 치환 대상(#{key} 포함)
+ * @param array $data 변수 치환 소스
+ * @return string 치환된 문자열
+ */
+ public function substituteText(string $text, array $data): string
+ {
+ return $this->substitute($text, $data);
+ }
+
+ /**
+ * 카카오 변수(#{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