Merge pull request from gnuboard:HeuJung/issue597
HeuJung/issue597
This commit is contained in:
@@ -155,7 +155,7 @@
|
|||||||
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
|
| 코어 | [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` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
|
> 각 확장이 소유하는 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-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-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-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_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_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 |
|
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||||
|
|||||||
+9
-1
@@ -4,13 +4,21 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
## [7.0.9] - 2026-08-22
|
## [7.0.9] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||||
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
|
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
|
||||||
|
- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
|
||||||
|
- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
|
||||||
|
- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
|
||||||
|
- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
|
||||||
|
|
||||||
## [7.0.8] - 2026-08-22
|
## [7.0.8] - 2026-08-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -112,11 +112,13 @@ class RunSeedersOnLanguagePackLifecycle
|
|||||||
*/
|
*/
|
||||||
private function refreshRegistry(): void
|
private function refreshRegistry(): void
|
||||||
{
|
{
|
||||||
// Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget
|
// boot 시점에 등록된 seed 필터 클로저들은 injector→registry "인스턴스"를 캡처하고 있다.
|
||||||
$this->app->forgetInstance(LanguagePackRegistry::class);
|
// forgetInstance 로 바인딩을 교체하면 캡처된 구 인스턴스의 stale 캐시(활성 팩 목록)가
|
||||||
$this->app->forgetInstance(LanguagePackSeedInjector::class);
|
// 그대로 남아, 같은 프로세스에서 활성화된 신규 팩이 코어 시더의 번역 필터에 보이지
|
||||||
|
// 않는다 (오류 없이 해당 로케일만 미주입). 싱글톤을 유지한 채 내부 캐시만 무효화해
|
||||||
|
// 캡처된 참조에도 전파한다 (#597 에서 실측·교정).
|
||||||
$registry = $this->app->make(LanguagePackRegistry::class);
|
$registry = $this->app->make(LanguagePackRegistry::class);
|
||||||
|
$registry->invalidate();
|
||||||
config([
|
config([
|
||||||
'app.supported_locales' => $registry->getActiveCoreLocales(),
|
'app.supported_locales' => $registry->getActiveCoreLocales(),
|
||||||
'app.locale_names' => $registry->getLocaleNames(),
|
'app.locale_names' => $registry->getLocaleNames(),
|
||||||
|
|||||||
@@ -9,17 +9,19 @@ use Illuminate\Support\Facades\File;
|
|||||||
use Illuminate\Support\Facades\Log;
|
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 메커니즘).
|
* 컬럼을 동기화하는 thin Listener (G7 표준 HookManager::doAction → addAction 메커니즘).
|
||||||
*
|
*
|
||||||
* 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일
|
* 영속/도메인 책임은 모두 LanguagePackTranslationRepository 에 위임. 본 클래스는 seed 파일
|
||||||
* 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다.
|
* 로딩 + Repository 호출 + 감사 로그 라우팅만 수행합니다.
|
||||||
*
|
*
|
||||||
* 정책 (계획서 §6.6, §9.4):
|
* 정책 (계획서 §6.6, §9.4) — 보존 판정은 운영자 수정의 실제 기록 형식인 dot-path
|
||||||
|
* 항목(`"{컬럼}.{로케일}"`, HasUserOverrides updating 이벤트가 기록)을 기준으로 한다:
|
||||||
* - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가
|
* - Case A: 기존 row 의 다국어 JSON 에 해당 locale 키 부재 → 추가
|
||||||
* - Case B: locale 키 존재 + user_overrides 에 컬럼 등록 → 건너뜀 (사용자 수정 보존)
|
* - Case B: locale 키 존재 + user_overrides 에 `컬럼.로케일` 등록 → 건너뜀 (사용자 수정 보존)
|
||||||
* - Case C: locale 키 존재 + user_overrides 미등록 → 시드 값으로 덮어쓰기
|
* - Case C: locale 키 존재 + 해당 dot-path 미등록 → 시드 값으로 덮어쓰기
|
||||||
* - 비활성화/제거 시: user_overrides 등록 locale 은 JSON 에서 제거하지 않음
|
* (컬럼 전체 항목 `"subject"` 등은 시더 재실행 보호 선언이라 팩 병합을 막지 않는다)
|
||||||
|
* - 비활성화/제거 시: user_overrides 에 `컬럼.로케일` 등록된 locale 은 JSON 에서 제거하지 않음
|
||||||
*/
|
*/
|
||||||
// audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님)
|
// audit:allow listener-must-implement-hooklistenerinterface reason: LanguagePackServiceProvider 가 HookManager::addAction 으로 직접 등록하는 명시 등록 패턴 (auto-discovery 대상 아님)
|
||||||
class SyncDatabaseTranslations
|
class SyncDatabaseTranslations
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ class IdentityMessageTemplate extends Model
|
|||||||
'is_active',
|
'is_active',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||||
|
* (NotificationTemplate 과 동형 — 미선언의 결과는 그쪽 주석 참조, #597 에서 교정)
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected array $translatableTrackableFields = [
|
||||||
|
'subject',
|
||||||
|
'body',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 활동 로그 추적 필드.
|
* 활동 로그 추적 필드.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ class NotificationTemplate extends Model
|
|||||||
'is_active',
|
'is_active',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 다국어 JSON 컬럼 — 운영자 수정을 dot-path(`"subject.ja"` 등) sub-key 단위로 기록.
|
||||||
|
*
|
||||||
|
* 미선언 시 수정이 컬럼 전체 항목(`"subject"`)으로 기록되어 시더 재실행이 컬럼을
|
||||||
|
* 통째로 얼리고(다른 로케일 갱신·신규 팩 로케일 추가 불가), 언어팩 병합의
|
||||||
|
* 사용자 수정 보존(Case B)도 로케일 단위로 판정할 수 없다 (#597 에서 교정).
|
||||||
|
*
|
||||||
|
* @var array<int, string>
|
||||||
|
*/
|
||||||
|
protected array $translatableTrackableFields = [
|
||||||
|
'subject',
|
||||||
|
'body',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제.
|
* 모델 이벤트 등록 — 템플릿 변경 시 알림 정의 캐시 자동 삭제.
|
||||||
*/
|
*/
|
||||||
@@ -110,13 +124,6 @@ class NotificationTemplate extends Model
|
|||||||
return $this->belongsTo(User::class, 'updated_by');
|
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);
|
return ! empty($this->recipients);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 채널의 템플릿 조회.
|
||||||
|
*
|
||||||
|
* @param Builder $query
|
||||||
|
* @param string $channel
|
||||||
|
* @return Builder
|
||||||
|
*/
|
||||||
public function scopeByChannel(Builder $query, string $channel): Builder
|
public function scopeByChannel(Builder $query, string $channel): Builder
|
||||||
{
|
{
|
||||||
return $query->where('channel', $channel);
|
return $query->where('channel', $channel);
|
||||||
|
|||||||
@@ -41,29 +41,37 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
|||||||
*/
|
*/
|
||||||
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
|
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
|
||||||
{
|
{
|
||||||
$audit = [];
|
// 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가
|
||||||
$locale = $pack->locale;
|
// 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이
|
||||||
|
// 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정).
|
||||||
|
app()->instance('user_overrides.seeding', true);
|
||||||
|
try {
|
||||||
|
$audit = [];
|
||||||
|
$locale = $pack->locale;
|
||||||
|
|
||||||
if (! empty($seedBundle['permissions'])) {
|
if (! empty($seedBundle['permissions'])) {
|
||||||
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
|
$this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||||
}
|
}
|
||||||
if (! empty($seedBundle['roles'])) {
|
if (! empty($seedBundle['roles'])) {
|
||||||
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
|
$this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
|
||||||
}
|
}
|
||||||
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
|
if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
|
||||||
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
|
$this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
|
||||||
}
|
}
|
||||||
if (! empty($seedBundle['notifications'])) {
|
if (! empty($seedBundle['notifications'])) {
|
||||||
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
|
$this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
|
||||||
}
|
}
|
||||||
if (! empty($seedBundle['identity_messages'])) {
|
if (! empty($seedBundle['identity_messages'])) {
|
||||||
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
|
$this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
|
||||||
}
|
}
|
||||||
if (! empty($seedBundle['manifest'])) {
|
if (! empty($seedBundle['manifest'])) {
|
||||||
$this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
|
$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<int, array<string, mixed>> 감사 로그 항목
|
* @return array<int, array<string, mixed>> 감사 로그 항목
|
||||||
*/
|
*/
|
||||||
public function stripLocaleFromPack(LanguagePack $pack): 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<int, array<string, mixed>> 감사 로그 항목
|
||||||
|
*/
|
||||||
|
private function stripLocaleFromPackBody(LanguagePack $pack): array
|
||||||
{
|
{
|
||||||
$audit = [];
|
$audit = [];
|
||||||
$locale = $pack->locale;
|
$locale = $pack->locale;
|
||||||
@@ -402,7 +427,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
|||||||
}
|
}
|
||||||
|
|
||||||
$hasLocaleKey = array_key_exists($locale, $current);
|
$hasLocaleKey = array_key_exists($locale, $current);
|
||||||
$isOverridden = isset($overrides[$column]);
|
$isOverridden = $this->isColumnLocaleOverridden($overrides, $column, $locale);
|
||||||
|
|
||||||
if ($hasLocaleKey && $isOverridden) {
|
if ($hasLocaleKey && $isOverridden) {
|
||||||
$audit[] = [
|
$audit[] = [
|
||||||
@@ -413,6 +438,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
|||||||
'locale' => $locale,
|
'locale' => $locale,
|
||||||
'reason' => 'user_overrides',
|
'reason' => 'user_overrides',
|
||||||
];
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,7 +469,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($overrides[$column])) {
|
if ($this->isColumnLocaleOverridden($overrides, $column, $locale)) {
|
||||||
$audit[] = [
|
$audit[] = [
|
||||||
'action' => 'preserved',
|
'action' => 'preserved',
|
||||||
'table' => $row->getTable(),
|
'table' => $row->getTable(),
|
||||||
@@ -452,6 +478,7 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
|||||||
'locale' => $locale,
|
'locale' => $locale,
|
||||||
'reason' => 'user_overrides',
|
'reason' => 'user_overrides',
|
||||||
];
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,4 +491,27 @@ class LanguagePackTranslationRepository implements LanguagePackTranslationReposi
|
|||||||
$row->saveQuietly();
|
$row->saveQuietly();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다.
|
||||||
|
*
|
||||||
|
* 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` —
|
||||||
|
* HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로
|
||||||
|
* 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤
|
||||||
|
* 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가
|
||||||
|
* 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정).
|
||||||
|
*
|
||||||
|
* 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은
|
||||||
|
* 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩
|
||||||
|
* 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음).
|
||||||
|
*
|
||||||
|
* @param array<int, string> $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
|
|||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
use App\Models\IdentityMessageDefinition;
|
use App\Models\IdentityMessageDefinition;
|
||||||
use App\Models\IdentityMessageTemplate;
|
use App\Models\IdentityMessageTemplate;
|
||||||
|
use App\Services\LanguagePack\LanguagePackSeedInjector;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class IdentityMessageTemplateService
|
|||||||
private readonly IdentityMessageTemplateRepositoryInterface $repository,
|
private readonly IdentityMessageTemplateRepositoryInterface $repository,
|
||||||
private readonly IdentityMessageDefinitionService $definitionService,
|
private readonly IdentityMessageDefinitionService $definitionService,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly LanguagePackSeedInjector $seedInjector,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -271,11 +273,19 @@ class IdentityMessageTemplateService
|
|||||||
{
|
{
|
||||||
$coreDefinitions = $this->loadCoreMessageDefinitions();
|
$coreDefinitions = $this->loadCoreMessageDefinitions();
|
||||||
|
|
||||||
return HookManager::applyFilters(
|
$definitions = HookManager::applyFilters(
|
||||||
'core.identity.filter_default_message_definitions',
|
'core.identity.filter_default_message_definitions',
|
||||||
$coreDefinitions,
|
$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();
|
$common = $this->commonVariables();
|
||||||
$result = [];
|
$result = [];
|
||||||
|
|
||||||
foreach ($messages as $data) {
|
foreach ($messages as $key => $data) {
|
||||||
if (($data['variables'] ?? null) === '__common__') {
|
if (($data['variables'] ?? null) === '__common__') {
|
||||||
$data['variables'] = $common;
|
$data['variables'] = $common;
|
||||||
}
|
}
|
||||||
$data['extension_type'] = 'core';
|
$data['extension_type'] = 'core';
|
||||||
$data['extension_identifier'] = 'core';
|
$data['extension_identifier'] = 'core';
|
||||||
$result[] = $data;
|
// config 키('mail.provider_default' 등)를 보존 — 언어팩 seed 매칭 키와 동일 (collectDefaultDefinitions 참조)
|
||||||
|
$result[$key] = $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ class LanguagePackSeedInjector
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($definitions as $defIdx => $def) {
|
foreach ($definitions as $defIdx => $def) {
|
||||||
$type = $def['type'] ?? null;
|
$type = $this->resolveDefinitionType($defIdx, $def);
|
||||||
if (! $type || ! isset($seed[$type])) {
|
if (! $type || ! isset($seed[$type])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -166,7 +166,7 @@ class LanguagePackSeedInjector
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach ($definitions as $defIdx => $def) {
|
foreach ($definitions as $defIdx => $def) {
|
||||||
$type = $def['type'] ?? null;
|
$type = $this->resolveDefinitionType($defIdx, $def);
|
||||||
if (! $type || ! isset($seed[$type])) {
|
if (! $type || ! isset($seed[$type])) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -722,4 +722,30 @@ class LanguagePackSeedInjector
|
|||||||
|
|
||||||
return $config;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1814,10 +1814,21 @@ class LanguagePackService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 복원 (DB 행이 finalizeInstall 에서 status 를 다시 쓰지 않은 경우만)
|
// 상태 복원 — 파일은 위에서 백업으로 원상 복원되므로 상태도 이전 상태로 되돌린다.
|
||||||
|
// Updating: 설치 트랜잭션 이전에 실패한 경우.
|
||||||
|
// Installed: 설치 트랜잭션이 installed 를 기록한 뒤(활성화 단계 이전/도중) 실패한 경우 —
|
||||||
|
// 여기서 복원하지 않으면 active 였던 팩이 installed 로 방치되어 해당 로케일의
|
||||||
|
// 백엔드 번역이 오류·로그 없이 통째로 폴백된다 (#597 보완 실측에서 유사 상태 실측).
|
||||||
|
// 활성화가 성공적으로 끝난 뒤(active) 실패한 경우는 덮어쓰지 않는다.
|
||||||
try {
|
try {
|
||||||
$current = $this->repository->findByIdentifier($identifier);
|
$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]);
|
$this->repository->update($current, ['status' => $previousStatus]);
|
||||||
}
|
}
|
||||||
} catch (Throwable $statusError) {
|
} catch (Throwable $statusError) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
|
|||||||
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
|
use App\Contracts\Repositories\NotificationTemplateRepositoryInterface;
|
||||||
use App\Extension\HookManager;
|
use App\Extension\HookManager;
|
||||||
use App\Models\NotificationTemplate;
|
use App\Models\NotificationTemplate;
|
||||||
|
use App\Services\LanguagePack\LanguagePackSeedInjector;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
class NotificationTemplateService
|
class NotificationTemplateService
|
||||||
@@ -33,6 +34,7 @@ class NotificationTemplateService
|
|||||||
private readonly NotificationTemplateRepositoryInterface $templateRepository,
|
private readonly NotificationTemplateRepositoryInterface $templateRepository,
|
||||||
private readonly NotificationDefinitionRepositoryInterface $definitionRepository,
|
private readonly NotificationDefinitionRepositoryInterface $definitionRepository,
|
||||||
private readonly CacheInterface $cache,
|
private readonly CacheInterface $cache,
|
||||||
|
private readonly LanguagePackSeedInjector $seedInjector,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -189,6 +191,11 @@ class NotificationTemplateService
|
|||||||
['type' => $type, 'channel' => $channel]
|
['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) {
|
foreach ($definitions as $def) {
|
||||||
if ($def['type'] === $type) {
|
if ($def['type'] === $type) {
|
||||||
foreach ($def['templates'] as $tpl) {
|
foreach ($def['templates'] as $tpl) {
|
||||||
@@ -207,6 +214,36 @@ class NotificationTemplateService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 정의 배열에 활성 언어팩 seed 로케일을 병합합니다.
|
||||||
|
*
|
||||||
|
* 코어 소유 정의는 활성 코어 팩(injectNotifications), 확장 소유 정의는
|
||||||
|
* 해당 확장의 활성 모듈/플러그인 팩(injectExtensionNotifications)이 공급한다.
|
||||||
|
* definition type 은 전역 고유(UNIQUE)이므로 확장별 호출이 다른 확장의
|
||||||
|
* 정의에 오병합될 여지는 없다.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $definitions type 필드를 포함한 기본 정의 배열
|
||||||
|
* @return array<int, array<string, mixed>> 활성 팩 로케일이 병합된 정의 배열
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 미리보기용 렌더링.
|
* 미리보기용 렌더링.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -72,20 +72,25 @@ class IdentityMessageDefinitionSeeder extends Seeder
|
|||||||
* `extension_type='core'` / `extension_identifier='core'` 자동 주입.
|
* `extension_type='core'` / `extension_identifier='core'` 자동 주입.
|
||||||
* `variables: '__common__'` 마커는 commonVariables() 로 expand.
|
* `variables: '__common__'` 마커는 commonVariables() 로 expand.
|
||||||
*
|
*
|
||||||
* @return array<int, array<string, mixed>>
|
* @return array<string, array<string, mixed>> config 복합 키('mail.purpose.signup' 등) => 정의
|
||||||
*/
|
*/
|
||||||
private function getDefaultDefinitions(): array
|
private function getDefaultDefinitions(): array
|
||||||
{
|
{
|
||||||
$messages = config('core.identity_messages', []);
|
$messages = config('core.identity_messages', []);
|
||||||
$result = [];
|
$result = [];
|
||||||
|
|
||||||
foreach ($messages as $data) {
|
foreach ($messages as $key => $data) {
|
||||||
if (($data['variables'] ?? null) === '__common__') {
|
if (($data['variables'] ?? null) === '__common__') {
|
||||||
$data['variables'] = $this->commonVariables();
|
$data['variables'] = $this->commonVariables();
|
||||||
}
|
}
|
||||||
$data['extension_type'] = 'core';
|
$data['extension_type'] = 'core';
|
||||||
$data['extension_identifier'] = 'core';
|
$data['extension_identifier'] = 'core';
|
||||||
$result[] = $data;
|
// config 복합 키('mail.purpose.signup' 등)를 보존 — 언어팩 주입 필터
|
||||||
|
// (seed.identity_messages.translations)가 이 키로 seed 를 매칭한다.
|
||||||
|
// 리스트로 만들면 전 항목이 매칭 불가로 스킵되어 팩 로케일이 오류 없이
|
||||||
|
// 주입되지 않는다 (#597 에서 실측·교정 — IdentityMessageTemplateService
|
||||||
|
// ::loadCoreMessageDefinitions 의 동형 보존과 짝을 이룬다).
|
||||||
|
$result[$key] = $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
@@ -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-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-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-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_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_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 |
|
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](../../../plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
|
||||||
|
|||||||
@@ -611,6 +611,49 @@ user_id 없는 비회원(주문자 이메일/이름만 보유)도 회원과 동
|
|||||||
- 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다.
|
- 중복 제거 키는 네임스페이스 분리: 회원 `user:{id}` / 비회원 `guest:{이메일 해시}` — null-id 게스트가 하나로 뭉개지지 않습니다.
|
||||||
- `exclude_trigger_user` 는 회원(user_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)
|
### 채널별 비회원 발송 허용 (allow_guest)
|
||||||
|
|
||||||
채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다.
|
채널이 비회원 발송을 허용하는지는 `config/notification.php` 채널 메타의 `allow_guest` 로 선언합니다.
|
||||||
|
|||||||
@@ -287,6 +287,48 @@ foreach ($config['categories'] as $cat) { ... }
|
|||||||
- `permissions.json` → `{scope}.{target}.permissions.translations` 필터 (3-레벨 트리: module/categories/permissions)
|
- `permissions.json` → `{scope}.{target}.permissions.translations` 필터 (3-레벨 트리: module/categories/permissions)
|
||||||
- 기타 → `seed.{target}.{entity}.translations` 필터 (단순 entity 시드)
|
- 기타 → `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 커버리지
|
### IDV 도메인 lang pack 커버리지
|
||||||
|
|
||||||
`config/core.php` 의 본인인증 SSoT 4개 블록 중 lang pack seed 대상은 1개:
|
`config/core.php` 의 본인인증 SSoT 4개 블록 중 lang pack seed 대상은 1개:
|
||||||
@@ -450,7 +492,20 @@ G7 는 일본어 번들 언어팩 12종을 공식 제공합니다. ko 원본을
|
|||||||
- 통화/숫자 단위 변환 금지 (런타임 처리)
|
- 통화/숫자 단위 변환 금지 (런타임 처리)
|
||||||
- 공식체(です·ます) 강제, 친근체 금지
|
- 공식체(です·ます) 강제, 친근체 금지
|
||||||
- 한글 미포함 value(영문/숫자/기호만) 는 번역 스킵 → 원문 유지
|
- 한글 미포함 value(영문/숫자/기호만) 는 번역 스킵 → 원문 유지
|
||||||
- 고정 용어집 50항목으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
|
- 고정 용어집으로 일관성 강제 (예: `관리자 → 管理者`, `장바구니 → カート`)
|
||||||
|
|
||||||
|
### 용어집 등록 의무
|
||||||
|
|
||||||
|
번역 결과의 표기는 고정 용어집이 정한다. 용어집에 없는 도메인 용어는 어간만 번역되어
|
||||||
|
**원본 로케일 문자가 값에 그대로 남는다** — 예외도 오류도 나지 않고, 키 대칭 검사도 값은 보지 않으므로
|
||||||
|
그 로케일로 화면을 열기 전까지 드러나지 않는다.
|
||||||
|
|
||||||
|
- 새 도메인 용어를 도입하면 번역 빌드 **전에** 용어집에 등록한다
|
||||||
|
- 이미 생성된 값을 손으로 고쳤다면 용어집도 함께 고친다 — 용어집을 두면 다음 키에서 같은 오역이 되풀이된다
|
||||||
|
- 같은 용어는 팩 전체에서 한 표기로 통일한다 (용어집이 단일 출처)
|
||||||
|
|
||||||
|
정적 검사가 번역 값의 원본 로케일 문자 잔존을 검출한다. 매니페스트와 CHANGELOG,
|
||||||
|
그리고 한국어 주석은 대상이 아니다.
|
||||||
|
|
||||||
### 설치 및 활성화
|
### 설치 및 활성화
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
## [1.0.8] - 2026-08-22
|
## [1.0.8] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
|
## [1.1.3] - 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 배송업체 이름(한진·롯데·로젠)이 일본어 화면에서 "택배" 부분만 한국어로 표시되던 문제를 수정했습니다. 관리자 배송정책 화면과 초기 배송업체 데이터 모두 일본어 표기로 통일했습니다.
|
||||||
|
- 모듈 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
|
||||||
|
|
||||||
## [1.1.2] - 2026-08-22
|
## [1.1.2] - 2026-08-22
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+3
-3
@@ -16,9 +16,9 @@
|
|||||||
},
|
},
|
||||||
"carrier": {
|
"carrier": {
|
||||||
"cj": "CJ大韓通運",
|
"cj": "CJ大韓通運",
|
||||||
"hanjin": "韓進택배",
|
"hanjin": "ハンジン宅配",
|
||||||
"lotte": "ロッテ택배",
|
"lotte": "ロッテ宅配",
|
||||||
"logen": "ロジェン택배",
|
"logen": "ロジェン宅配",
|
||||||
"ups": "UPS",
|
"ups": "UPS",
|
||||||
"fedex": "FedEx",
|
"fedex": "FedEx",
|
||||||
"dhl": "DHL",
|
"dhl": "DHL",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
|
||||||
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
|
||||||
},
|
},
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scope": "module",
|
"scope": "module",
|
||||||
"target_identifier": "sirsoft-ecommerce",
|
"target_identifier": "sirsoft-ecommerce",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "eコマース",
|
"name": "eコマース",
|
||||||
"description": "그누보드7 eコマース モジュール - 商品、注文、決済管理"
|
"description": "グヌボード7 eコマース モジュール - 商品、注文、決済管理"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
"name": "CJ大韓通運"
|
"name": "CJ大韓通運"
|
||||||
},
|
},
|
||||||
"hanjin": {
|
"hanjin": {
|
||||||
"name": "韓進택배"
|
"name": "ハンジン宅配"
|
||||||
},
|
},
|
||||||
"lotte": {
|
"lotte": {
|
||||||
"name": "ロッテ택배"
|
"name": "ロッテ宅配"
|
||||||
},
|
},
|
||||||
"logen": {
|
"logen": {
|
||||||
"name": "ロジェン택배"
|
"name": "ロジェン宅配"
|
||||||
},
|
},
|
||||||
"ups": {
|
"ups": {
|
||||||
"name": "UPS"
|
"name": "UPS"
|
||||||
|
|||||||
@@ -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) 섹션의 제목·안내 문구와 승인 여부 배지(승인됨 / 미승인)의 일본어 번역
|
||||||
|
- 검수자 전달 의견 입력란의 라벨·안내 문구의 일본어 번역
|
||||||
|
- 알림톡 템플릿 작성·검수 신청·승인 관리 화면 문구의 일본어 번역
|
||||||
|
- 템플릿 통합 관리 화면과 발송 이력 화면 문구의 일본어 번역
|
||||||
|
- 발송 결과 안내 문구의 일본어 번역
|
||||||
|
- 템플릿 작성 시 입력값 오류 안내의 일본어 번역 — 항목 이름까지 일본어로 표시됩니다.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'action' => [],
|
||||||
|
'description' => [],
|
||||||
|
];
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'channel' => [
|
||||||
|
'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' => '認証の確認に失敗しました。詳細な理由をご確認ください。',
|
||||||
|
],
|
||||||
|
];
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'1000' => '成功',
|
||||||
|
'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' => '後払い限度額超過',
|
||||||
|
];
|
||||||
@@ -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": "ワンクリック決済"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Bizppurio メッセージ発送",
|
||||||
|
"description": "Bizppurio 連動 SMS/LMS・カカオ アラート トーク発送プラグインです。コア通知システムチャネルとして文字・アラートトークを発送し、発送結果を webhook で受信します。"
|
||||||
|
}
|
||||||
@@ -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})。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "環境設定·アラートトークテンプレート登録/検証·イベント連動管理"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
## [1.0.7] - 2026-08-22
|
## [1.0.7] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다.
|
- 환경설정 SEO 탭의 기본 공유 이미지(og:image) 라벨·설명 번역을 추가했습니다.
|
||||||
|
- 알림 설정 화면의 「사이트맵 재생성 완료」・「사이트맵 재생성 실패」 알림 이름 번역을 추가했습니다.
|
||||||
|
|
||||||
## [1.0.6] - 2026-08-22
|
## [1.0.6] - 2026-08-22
|
||||||
|
|
||||||
|
|||||||
@@ -1862,7 +1862,9 @@
|
|||||||
"types": {
|
"types": {
|
||||||
"welcome": "会員登録ウェルカム",
|
"welcome": "会員登録ウェルカム",
|
||||||
"reset_password": "パスワード再設定",
|
"reset_password": "パスワード再設定",
|
||||||
"password_changed": "パスワード変更通知"
|
"password_changed": "パスワード変更通知",
|
||||||
|
"sitemap_regenerated": "サイトマップ再生成完了",
|
||||||
|
"sitemap_regenerate_failed": "サイトマップ再生成失敗"
|
||||||
},
|
},
|
||||||
"channels_label": "チャネル:",
|
"channels_label": "チャネル:",
|
||||||
"edit_modal": {
|
"edit_modal": {
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
|
## [1.1.2] - 2026-08-24
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 첫 화면 소개 문구의 제품명이 한국어로 표시되던 문제를 수정했습니다.
|
||||||
|
|
||||||
## [1.1.1] - 2026-08-19
|
## [1.1.1] - 2026-08-19
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "いらっしゃいませ!",
|
"title": "いらっしゃいませ!",
|
||||||
"description": "그누보드7 コミュニティへようこそ。",
|
"description": "グヌボード7 コミュニティへようこそ。",
|
||||||
"login_prompt": "ログインしてコミュニティに参加してください。",
|
"login_prompt": "ログインしてコミュニティに参加してください。",
|
||||||
"greeting": "こんにちは、{{name}}さん!"
|
"greeting": "こんにちは、{{name}}さん!"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
"en": "G7 template (sirsoft-basic) Japanese language pack (bundled)",
|
||||||
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
"ja": "G7 テンプレート (sirsoft-basic) 日本語 言語パック(バンドル)"
|
||||||
},
|
},
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scope": "template",
|
"scope": "template",
|
||||||
"target_identifier": "sirsoft-basic",
|
"target_identifier": "sirsoft-basic",
|
||||||
|
|||||||
@@ -4,13 +4,18 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
## [1.1.0] - 2026-08-22
|
## [1.1.0] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
- 본문에 삽입한 이미지가 카드형·갤러리형 목록의 썸네일로 표시됩니다. 이미지 첨부파일이 있으면 종전대로 첨부가 우선하며, 첨부가 없을 때 본문의 첫 내부 이미지를 사용합니다. 외부 주소의 이미지는 사용하지 않습니다. 기존 게시물은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||||
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
|
- 글 상세를 공유할 때의 미리보기 이미지(og:image)에도 같은 기준이 적용됩니다.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||||
|
- 게시판 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||||
|
|
||||||
## [1.0.5] - 2026-08-22
|
## [1.0.5] - 2026-08-22
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|||||||
+158
@@ -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<string, any> | 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<string, any>;
|
||||||
|
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<string, any> | 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<string, any>;
|
||||||
|
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<Record<string, any>> = 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+138
@@ -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<string, any>;
|
||||||
|
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<string, unknown>): 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<string, unknown> = {}) => ({
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+52
-18
@@ -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",
|
"id": "board_template_lang_tabs",
|
||||||
"type": "basic",
|
"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",
|
"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",
|
"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",
|
"id": "board_template_click_url_input",
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Div",
|
"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": [
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
@@ -724,7 +741,7 @@
|
|||||||
"id": "board_template_variables_info",
|
"id": "board_template_variables_info",
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Div",
|
"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": {
|
"props": {
|
||||||
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
|
"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"
|
"target": "modal_board_notification_template_preview"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"onError": [
|
"onError": [
|
||||||
{
|
{
|
||||||
"handler": "toast",
|
"handler": "toast",
|
||||||
"params": {
|
"params": {
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "{{error.message}}"
|
"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",
|
"type": "basic",
|
||||||
"name": "Button",
|
"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)}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -140,7 +140,7 @@
|
|||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Button",
|
"name": "Button",
|
||||||
"iteration": {
|
"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",
|
"item_var": "ch",
|
||||||
"index_var": "chIdx"
|
"index_var": "chIdx"
|
||||||
},
|
},
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
{
|
{
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Span",
|
"name": "Span",
|
||||||
"text": "{{ch.name ?? ch.id}}"
|
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
|
|||||||
@@ -4,12 +4,17 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
## [1.2.0] - 2026-08-22
|
## [1.2.0] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
- 상품 이미지를 등록하지 않은 상품은 상세설명(에디터)의 첫 이미지가 상품 목록·검색 결과·공유 미리보기(og:image) 이미지로 사용됩니다. 상품 이미지가 있으면 종전대로 상품 이미지가 우선하며, 외부 주소의 이미지는 사용하지 않습니다. 기존 상품은 모듈 업데이트 시 자동 반영됩니다. (#22 @abc101 님께서 제보해주셨습니다.)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 알림 설정의 채널 서브탭이 확장 채널의 통합 탭 선언을 지원합니다. 묶인 채널 중 하나라도 켜져 있으면 통합 탭이 노출됩니다.
|
||||||
|
- 이커머스 알림 템플릿 편집 창이 확장 채널 전용 편집 영역을 제공합니다. 채널을 제공하는 확장이 그 채널의 템플릿 편집기와 저장 버튼을 같은 창 안에 넣을 수 있으며, 이때 코어의 제목/본문 입력과 미리보기·저장은 숨겨지고 수신자 규칙은 그대로 편집할 수 있습니다.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 카테고리 페이지의 공유 미리보기 이미지(og:image)가 어떤 경우에도 채워지지 않던 문제를 수정했습니다. 이제 카테고리에 등록된 첫 이미지가 사용됩니다.
|
- 카테고리 페이지의 공유 미리보기 이미지(og:image)가 어떤 경우에도 채워지지 않던 문제를 수정했습니다. 이제 카테고리에 등록된 첫 이미지가 사용됩니다.
|
||||||
|
|||||||
+158
@@ -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<string, any> | 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<string, any>;
|
||||||
|
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<string, any> | 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<string, any>;
|
||||||
|
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<Record<string, any>> = 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+138
@@ -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<string, any>;
|
||||||
|
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<string, unknown>): 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<string, unknown> = {}) => ({
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+52
-18
@@ -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",
|
"id": "ecommerce_template_lang_tabs",
|
||||||
"type": "basic",
|
"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",
|
"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",
|
"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",
|
"id": "ecommerce_template_click_url_input",
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Div",
|
"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": [
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
@@ -724,7 +741,7 @@
|
|||||||
"id": "ecommerce_template_variables_info",
|
"id": "ecommerce_template_variables_info",
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Div",
|
"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": {
|
"props": {
|
||||||
"className": "bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md p-4"
|
"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"
|
"target": "modal_ecommerce_notification_definition_preview"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"onError": [
|
"onError": [
|
||||||
{
|
{
|
||||||
"handler": "toast",
|
"handler": "toast",
|
||||||
"params": {
|
"params": {
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": "{{error.message}}"
|
"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",
|
"type": "basic",
|
||||||
"name": "Button",
|
"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)}}"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -139,7 +139,7 @@
|
|||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Button",
|
"name": "Button",
|
||||||
"iteration": {
|
"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",
|
"item_var": "ch",
|
||||||
"index_var": "chIdx"
|
"index_var": "chIdx"
|
||||||
},
|
},
|
||||||
@@ -159,7 +159,7 @@
|
|||||||
{
|
{
|
||||||
"type": "basic",
|
"type": "basic",
|
||||||
"name": "Span",
|
"name": "Span",
|
||||||
"text": "{{ch.name ?? ch.id}}"
|
"text": "{{ch.tab_label_key ? $t(ch.tab_label_key) : (ch.name ?? ch.id)}}"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"actions": [
|
"actions": [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||||
|
|
||||||
## [1.1.0] - 2026-08-22
|
## [1.1.0] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
|||||||
@@ -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 이상이 필요합니다.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
프로그램 명칭 : 그누보드7용 비즈뿌리오 메시징 플러그인 (sirsoft-message_bizppurio)
|
||||||
|
|
||||||
|
저작자 : (주)에스아이알소프트
|
||||||
|
|
||||||
|
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
|
||||||
|
|
||||||
|
MIT 라이선스
|
||||||
|
|
||||||
|
Copyright (c) 2026 (주)에스아이알소프트
|
||||||
|
|
||||||
|
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
|
||||||
|
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
|
||||||
|
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
|
||||||
|
조건을 따라야 합니다:
|
||||||
|
|
||||||
|
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
|
||||||
|
합니다.
|
||||||
|
|
||||||
|
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
|
||||||
|
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
|
||||||
|
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
|
||||||
|
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
|
||||||
|
책임에 대해 책임을 지지 않습니다.
|
||||||
|
|
||||||
|
----- MIT License (English Original) --------------------------------------------------------
|
||||||
|
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2026 SIRSOFT
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Bizppurio Messaging Plugin for G7
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/version-1.0.0-blue" alt="Version">
|
||||||
|
<img src="https://img.shields.io/badge/G7-%3E%3D7.0.6-0066FF" alt="G7">
|
||||||
|
<img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
비즈뿌리오(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
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "plugins/sirsoft-message_bizppurio",
|
||||||
|
"description": "Bizppurio SMS/LMS and KakaoTalk alimtalk messaging plugin for G7 platform by sirsoft",
|
||||||
|
"type": "library",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "sirsoft",
|
||||||
|
"email": "contact@sirsoft.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"require": {
|
||||||
|
"php": "^8.2",
|
||||||
|
"ext-json": "*"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Plugins\\Sirsoft\\MessageBizppurio\\": ["src/", "./"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈뿌리오 발송 이력 테이블.
|
||||||
|
*
|
||||||
|
* 문자(SMS/LMS)·알림톡 1건 발송마다 1행. 발송 시 pending 으로 생성되고,
|
||||||
|
* 비즈뿌리오 webhook(URL PUSH) 리포트 수신 시 refkey 로 매칭해 상태를 갱신한다.
|
||||||
|
* 발송 이력 화면(계획서 §6-5)의 데이터소스.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('bizppurio_dispatches');
|
||||||
|
|
||||||
|
Schema::create('bizppurio_dispatches', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
+68
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈뿌리오 알림 템플릿 테이블 (#597).
|
||||||
|
*
|
||||||
|
* 알림 1건(notification_definitions.type)당 1행. 알림톡 템플릿의 카카오 등록
|
||||||
|
* 페이로드(content)·승인 스냅샷(approved_content)·검수 상태와, 대체 SMS/SMS 단독
|
||||||
|
* 본문(sms_body)을 함께 저장한다. 발송 시 DB 가 유일한 판정 근거다(실시간 조회 폐지).
|
||||||
|
*
|
||||||
|
* sms_body 는 다국어 맵이다 — 알림톡 content 는 카카오가 승인한 원문 그대로만 발송할 수
|
||||||
|
* 있어 단일 언어 1벌이지만, SMS 에는 그 제약이 없으므로 코어 알림 템플릿과 동일하게
|
||||||
|
* 수신자 로케일별 본문을 유지한다(개편 전 동작 보존).
|
||||||
|
*
|
||||||
|
* bizppurio_notification_bindings 를 대체한다 — 공개 릴리즈 이력이 없어 마이그레이션
|
||||||
|
* 자체를 교체했다(업그레이드 스텝 불요, 개발 환경은 플러그인 재설치 필요).
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('bizppurio_templates');
|
||||||
|
|
||||||
|
Schema::create('bizppurio_templates', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,19 @@
|
|||||||
|
# API 레퍼런스 문서 목차
|
||||||
|
|
||||||
|
> **소유**: 플러그인 `sirsoft-message_bizppurio` · **생성**: `php artisan api:docgen` (실측 기반).
|
||||||
|
> 아래 표는 자동 생성됩니다. 각 문서를 열면 엔드포인트별 파라미터·응답·예시를 볼 수 있습니다.
|
||||||
|
|
||||||
|
<!-- @generated:start:api-readme-index -->
|
||||||
|
- **문서 수**: 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 |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
@@ -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
|
||||||
|
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories -->
|
||||||
|
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.categories`
|
||||||
|
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@categories`
|
||||||
|
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
|
||||||
|
|
||||||
|
**요청 파라미터**
|
||||||
|
|
||||||
|
_요청 파라미터 없음._
|
||||||
|
|
||||||
|
**요청 예시**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/categories HTTP/1.1
|
||||||
|
Host: api.example.com
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {YOUR_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
|
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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`)이 없는 경우 |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
**설명**
|
||||||
|
|
||||||
|
템플릿 등록에 사용할 카카오 카테고리 전체(대분류·소분류)를 조회한다(`data.categories`). 작성 모달의
|
||||||
|
카테고리 셀렉트가 소비하며, 선택된 소분류 코드가 템플릿 생성/수정의 `content.categoryCode` 값이 된다.
|
||||||
|
kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다.
|
||||||
|
|
||||||
|
|
||||||
|
### GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles
|
||||||
|
<!-- @generated:start:api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles -->
|
||||||
|
- **라우트명**: `api.plugins.sirsoft-message_bizppurio.admin.alimtalk-templates.profiles`
|
||||||
|
- **컨트롤러**: `Plugins\Sirsoft\MessageBizppurio\Controllers\Admin\AlimtalkTemplateController@profiles`
|
||||||
|
- **인증/권한**: `auth:sanctum` + `permission:sirsoft-message_bizppurio.messaging.view`
|
||||||
|
|
||||||
|
**요청 파라미터**
|
||||||
|
|
||||||
|
_요청 파라미터 없음._
|
||||||
|
|
||||||
|
**요청 예시**
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/plugins/sirsoft-message_bizppurio/admin/alimtalk-templates/profiles HTTP/1.1
|
||||||
|
Host: api.example.com
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {YOUR_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 필드** (`data` 내부)
|
||||||
|
|
||||||
|
| 필드 | 타입 | 예시값 | 용도/설명 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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`)이 없는 경우 |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
**설명**
|
||||||
|
|
||||||
|
발신프로필(사용중) 상태 정보를 조회한다(`data.profiles`). 작성 모달의 발신프로필 셀렉트·상태 표시가
|
||||||
|
소비한다. kapi 자격증명 미설정·호출 실패 시 422 + `errors.bizppurio_message`(카카오 사유 원문) 를 반환한다.
|
||||||
@@ -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
|
||||||
|
<!-- @generated:start: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` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
**설명**
|
||||||
|
|
||||||
|
코어 "알림 발송 이력" 화면에 얹은 결과 컬럼(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
|
||||||
|
<!-- @generated:start: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`)이 없는 경우 |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
**설명**
|
||||||
|
|
||||||
|
알림 발송 이력 화면이 최근 발송 건의 비즈뿌리오 결과를 한 번에 받아 결과 컬럼에 주입할 때 사용한다.
|
||||||
|
`lookup` 이 지정한 로그 ID 만 조회하는 것과 달리, 이 엔드포인트는 최근 구간을 서버가 정해 돌려주므로
|
||||||
|
화면이 조회 대상을 미리 알 필요가 없다. 결과는 `notification_log_id` 를 키로 하는 맵이며,
|
||||||
|
비즈뿌리오로 발송되지 않은 알림은 키 자체가 없다 — 화면은 키 부재를 "해당 없음" 으로 그린다.
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
|
<!-- @generated:start: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`)이 없는 경우 |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
**설명**
|
||||||
|
|
||||||
|
비즈뿌리오 발송 결과(리포트)를 수신할 콜백 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`)는 후속 단계에서 제공됩니다. 본 엔드포인트는 표시용 주소 조회만 담당합니다.
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
<!-- @generated:start: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` 없음) |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
**설명**
|
||||||
|
|
||||||
|
관리자가 설정 화면에서 저장한 비즈뿌리오 아이디·비밀번호가 실제로 유효한지 그 자리에서 확인하는 "연결 확인" 버튼이 호출하는 엔드포인트입니다. `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` 권한을 요구합니다.
|
||||||
@@ -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
|
||||||
|
<!-- @generated:start: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` 에 필드별 메시지) |
|
||||||
|
|
||||||
|
<!-- @generated:end -->
|
||||||
|
|
||||||
|
**설명**
|
||||||
|
|
||||||
|
비즈뿌리오가 문자·알림톡 발송 결과를 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 멱등이 중복 처리를 막는다.
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// changelog:allow No user-facing string change — comment-only clarification.
|
||||||
|
// This plugin does not write activity logs yet. Define action/description keys here
|
||||||
|
// when activity logging is added.
|
||||||
|
// (Action labels live in the core lang/{ko,en}/activity_log.php SSoT — last-segment fallback.)
|
||||||
|
return [
|
||||||
|
'action' => [],
|
||||||
|
'description' => [],
|
||||||
|
];
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// 발송 채널 (DispatchChannel enum)
|
||||||
|
'channel' => [
|
||||||
|
'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.',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Bizppurio result code → reason (English)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Source: Bizppurio official response-code docs (https://bizppurio.github.io/response-codes/)
|
||||||
|
| As of: 2026-07-27 (aligned to the latest official docs; legacy manual wording corrected)
|
||||||
|
| Scope: common(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + Alimtalk(7000~7523).
|
||||||
|
| RCS(8000s)/NaverTalkTalk(part of 5000s) are out of scope (follow-up D15).
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ── Common / send response ───────────────────────────
|
||||||
|
'1000' => '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)',
|
||||||
|
];
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
// changelog:allow 사용자 문구 변경 없음 — 빈 배열 유지, 주석(안내)만 현행화
|
||||||
|
// 이 플러그인은 현재 활동로그를 기록하지 않는다.
|
||||||
|
// 활동로그 기록을 추가할 때 action/description 키를 여기에 정의한다.
|
||||||
|
// (action 라벨은 코어 lang/{ko,en}/activity_log.php 가 SSoT — 마지막 세그먼트 기준 fallback)
|
||||||
|
return [
|
||||||
|
'action' => [],
|
||||||
|
'description' => [],
|
||||||
|
];
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
// 발송 채널 (DispatchChannel enum)
|
||||||
|
'channel' => [
|
||||||
|
'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' => '인증 확인에 실패했습니다. 상세 사유를 확인해 주세요.',
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| 비즈뿌리오 결과 코드 → 사유 (한국어)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| 출처: 비즈뿌리오 공식 응답 코드 문서 (https://bizppurio.github.io/response-codes/)
|
||||||
|
| 기준일: 2026-07-27 (최신 공식 문서 기준으로 정합화 — 구 매뉴얼 표기 정정)
|
||||||
|
| 범위: 공통(2000~9071) + SMS(4100~4443) + LMS(6600~6641) + 알림톡(7000~7523)
|
||||||
|
| RCS(8000대)·네이버톡톡(5000대 일부)은 범위 밖(후속 D15).
|
||||||
|
|
|
||||||
|
| 매뉴얼이 삭제되어도 이 파일이 원본 스펙을 보존한다. 새 코드 추가 시 분류는
|
||||||
|
| ResultCodeResolver 의 상수 배열(SUCCESS/RETRYABLE/BALANCE_LOW)과 함께 갱신한다.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
// ── 공통 / 발송 응답 ──────────────────────────────────
|
||||||
|
'1000' => '성공',
|
||||||
|
'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 수신거부(스팸)',
|
||||||
|
];
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\MessageBizppurio;
|
||||||
|
|
||||||
|
use App\Enums\ExtensionOwnerType;
|
||||||
|
use App\Extension\AbstractPlugin;
|
||||||
|
use App\Extension\Helpers\ExtensionMenuSyncHelper;
|
||||||
|
use App\Extension\Helpers\NotificationSyncHelper;
|
||||||
|
use App\Extension\HookListenerRegistrar;
|
||||||
|
use App\Extension\ModuleManager;
|
||||||
|
use App\Models\NotificationDefinition;
|
||||||
|
use Database\Seeders\NotificationDefinitionSeeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Http\Middleware\BizppurioWebhookIpWhitelist;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Listeners\BalanceLowNotificationDataListener;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Listeners\GuestPhoneExtractListener;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Listeners\InvalidateTokenOnSettingsSaveListener;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Listeners\LinkNotificationLogListener;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Listeners\RegisterNotificationChannelsListener;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Listeners\SeedChannelTemplatesListener;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Listeners\ValidateBizppurioSettingsListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈뿌리오 메시지 발송 플러그인
|
||||||
|
*
|
||||||
|
* 비즈뿌리오 연동 SMS/LMS·카카오 알림톡 발송을 제공합니다.
|
||||||
|
* 코어 알림 시스템의 채널로 문자·알림톡을 발송하고, 발송 결과를 webhook 으로
|
||||||
|
* 수신하여 이력에 기록합니다.
|
||||||
|
*/
|
||||||
|
class Plugin extends AbstractPlugin
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 플러그인 메타데이터 반환
|
||||||
|
*
|
||||||
|
* @return array 메타데이터
|
||||||
|
*/
|
||||||
|
public function getMetadata(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'author' => 'Sirsoft',
|
||||||
|
'license' => 'MIT',
|
||||||
|
'homepage' => 'https://sir.kr',
|
||||||
|
'keywords' => ['bizppurio', 'sms', 'lms', 'alimtalk', 'kakao', 'messaging', 'notification'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플러그인 활성화 — 기존 회원 알림에 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<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function getNotificationDefinitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'type' => 'bizppurio_balance_low',
|
||||||
|
'hook_prefix' => 'sirsoft-message_bizppurio',
|
||||||
|
'name' => [
|
||||||
|
'ko' => '비즈뿌리오 잔액 부족',
|
||||||
|
'en' => 'Bizppurio Balance Low',
|
||||||
|
],
|
||||||
|
'description' => [
|
||||||
|
'ko' => '비즈뿌리오 지갑 잔액이 부족해 문자/알림톡 발송이 실패했을 때 관리자에게 발송',
|
||||||
|
'en' => 'Sent to admin when a message fails due to insufficient Bizppurio wallet balance',
|
||||||
|
],
|
||||||
|
'channels' => ['mail', 'database'],
|
||||||
|
'hooks' => ['sirsoft-message_bizppurio.balance.low'],
|
||||||
|
'variables' => [
|
||||||
|
['key' => 'name', 'description' => '수신자(관리자) 이름'],
|
||||||
|
['key' => 'app_name', 'description' => '사이트 이름'],
|
||||||
|
['key' => 'result_code', 'description' => '결과 코드(9070 문자 / 7436 알림톡)'],
|
||||||
|
['key' => 'channel_label', 'description' => '발송 채널(문자/알림톡)'],
|
||||||
|
['key' => 'settings_url', 'description' => '메시징 환경설정 URL'],
|
||||||
|
['key' => 'site_url', 'description' => '사이트 URL'],
|
||||||
|
],
|
||||||
|
'templates' => [
|
||||||
|
[
|
||||||
|
'channel' => 'mail',
|
||||||
|
'recipients' => [['type' => 'role', 'value' => 'admin']],
|
||||||
|
'subject' => [
|
||||||
|
'ko' => '[{app_name}] 비즈뿌리오 잔액이 부족합니다',
|
||||||
|
'en' => '[{app_name}] Bizppurio balance is insufficient',
|
||||||
|
],
|
||||||
|
'body' => [
|
||||||
|
'ko' => '{name}님, 비즈뿌리오 지갑 잔액이 부족하여 {channel_label} 발송이 실패했습니다 (코드: {result_code}). 충전 후 발송이 정상화됩니다.',
|
||||||
|
'en' => 'Dear {name}, a {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}). Delivery resumes after recharging.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'channel' => 'database',
|
||||||
|
'recipients' => [['type' => 'role', 'value' => 'admin']],
|
||||||
|
'subject' => [
|
||||||
|
'ko' => '비즈뿌리오 잔액 부족',
|
||||||
|
'en' => 'Bizppurio balance low',
|
||||||
|
],
|
||||||
|
'body' => [
|
||||||
|
'ko' => '비즈뿌리오 잔액 부족으로 {channel_label} 발송이 실패했습니다 (코드: {result_code}).',
|
||||||
|
'en' => 'A {channel_label} message failed due to insufficient Bizppurio balance (code: {result_code}).',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 훅 리스너 목록 반환
|
||||||
|
*
|
||||||
|
* - RegisterNotificationChannelsListener: 채널 등록/readiness/3영역 노출(Phase 3)
|
||||||
|
* - SeedChannelTemplatesListener: 회원 알림에 sms·alimtalk template 증강(Phase 6 결정 D — 시딩 필터 훅)
|
||||||
|
* - GuestPhoneExtractListener: 비회원 주문 전화번호 주입(Phase 3)
|
||||||
|
* - LinkNotificationLogListener: 코어 알림 로그↔dispatch 연결(A-2 — 발송 이력 결과 주입 연결고리)
|
||||||
|
* - ValidateBizppurioSettingsListener: 환경설정 검증
|
||||||
|
* - InvalidateTokenOnSettingsSaveListener: 설정 저장 시 인증 토큰 캐시 무효화
|
||||||
|
*
|
||||||
|
* @return array<class-string>
|
||||||
|
*/
|
||||||
|
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<int, array<string, string>> 스케줄 정의 목록
|
||||||
|
*/
|
||||||
|
public function getSchedules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'command' => 'bizppurio:sync-template-status',
|
||||||
|
'schedule' => 'everyThirtyMinutes',
|
||||||
|
'description' => '비즈뿌리오 알림톡 템플릿 검수 상태 동기화',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확장 미들웨어 선언 (self-gate)
|
||||||
|
*
|
||||||
|
* webhook(URL PUSH) 리포트 수신 엔드포인트에만 IP 화이트리스트를 부착한다.
|
||||||
|
* 라우트 파일에서 직접 부착하지 않고 코어 게이트(ExtensionMiddlewareGate)가
|
||||||
|
* 요청 시점에 라우트 이름을 대조해 매칭될 때만 실행한다.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function getMiddleware(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'class' => BizppurioWebhookIpWhitelist::class,
|
||||||
|
'groups' => ['api'],
|
||||||
|
'targets' => [
|
||||||
|
'api.plugins.sirsoft-message_bizppurio.webhook',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+210
@@ -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
|
||||||
|
}
|
||||||
+195
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+202
@@ -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
|
||||||
|
}
|
||||||
+202
@@ -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
|
||||||
|
}
|
||||||
+202
@@ -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
|
||||||
|
}
|
||||||
+797
@@ -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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+2714
File diff suppressed because it is too large
Load Diff
+161
@@ -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<string, any> = {}) {
|
||||||
|
const state: Record<string, any> = { bz_tpl_modal: { content: {} }, bz_tpl_upload: {}, ...initial };
|
||||||
|
|
||||||
|
const setGlobal = vi.fn((updates: Record<string, unknown>) => {
|
||||||
|
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<typeof makeStore>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* 비즈뿌리오 메시징 플러그인 레이아웃 테스트 공통 헬퍼
|
||||||
|
*
|
||||||
|
* JSON 트리에서 ID/이름 검색 및 핸들러·i18n 키 추출을 위한 유틸리티.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AnyNode = Record<string, unknown> & {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
children?: AnyNode[];
|
||||||
|
slots?: Record<string, AnyNode[]>;
|
||||||
|
modals?: Record<string, AnyNode> | AnyNode[];
|
||||||
|
actions?: AnyNode[];
|
||||||
|
iteration?: { source?: string; item_var?: string; index_var?: string };
|
||||||
|
if?: string;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 노드에서 특정 id를 재귀 탐색 (children/slots/modals)
|
||||||
|
*/
|
||||||
|
export function findById(node: AnyNode | undefined | null, id: string): AnyNode | null {
|
||||||
|
if (!node) return null;
|
||||||
|
if (node.id === id) return node;
|
||||||
|
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
const found = findById(child, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.slots && typeof node.slots === 'object') {
|
||||||
|
for (const slotChildren of Object.values(node.slots)) {
|
||||||
|
if (Array.isArray(slotChildren)) {
|
||||||
|
for (const child of slotChildren) {
|
||||||
|
const found = findById(child, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.modals) {
|
||||||
|
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
|
||||||
|
for (const m of modalEntries) {
|
||||||
|
const found = findById(m as AnyNode, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트리 노드에서 특정 name 컴포넌트를 모두 수집 (children/slots/modals)
|
||||||
|
*/
|
||||||
|
export function findAllByName(node: AnyNode | undefined | null, name: string): AnyNode[] {
|
||||||
|
const results: AnyNode[] = [];
|
||||||
|
if (!node) return results;
|
||||||
|
if (node.name === name) results.push(node);
|
||||||
|
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
for (const child of node.children) {
|
||||||
|
results.push(...findAllByName(child, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.slots && typeof node.slots === 'object') {
|
||||||
|
for (const slotChildren of Object.values(node.slots)) {
|
||||||
|
if (Array.isArray(slotChildren)) {
|
||||||
|
for (const child of slotChildren) {
|
||||||
|
results.push(...findAllByName(child, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.modals) {
|
||||||
|
const modalEntries = Array.isArray(node.modals) ? node.modals : Object.values(node.modals);
|
||||||
|
for (const m of modalEntries) {
|
||||||
|
results.push(...findAllByName(m as AnyNode, name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 name 컴포넌트 중 props.name 속성이 주어진 값인 첫 노드를 찾음 (폼 입력 필드 탐색용)
|
||||||
|
*/
|
||||||
|
export function findInputByName(node: AnyNode | undefined | null, inputName: string): AnyNode | null {
|
||||||
|
for (const candidate of [...findAllByName(node, 'Input'), ...findAllByName(node, 'Select')]) {
|
||||||
|
if ((candidate.props as { name?: string } | undefined)?.name === inputName) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 문자열에서 사용된 핸들러 이름 모두 수집
|
||||||
|
*/
|
||||||
|
export function collectHandlers(json: unknown): string[] {
|
||||||
|
const text = JSON.stringify(json);
|
||||||
|
const matches = text.match(/"handler":\s*"([^"]+)"/g) ?? [];
|
||||||
|
const names = matches
|
||||||
|
.map((m) => m.match(/"handler":\s*"([^"]+)"/)?.[1] ?? '')
|
||||||
|
.filter(Boolean);
|
||||||
|
return Array.from(new Set(names));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON 문자열에서 사용된 i18n 키($t:sirsoft-message_bizppurio.*) 모두 수집
|
||||||
|
*/
|
||||||
|
export function collectI18nKeys(json: unknown): string[] {
|
||||||
|
const text = JSON.stringify(json);
|
||||||
|
const matches = text.match(/\$t:sirsoft-message_bizppurio\.[a-zA-Z0-9_.]+/g) ?? [];
|
||||||
|
return Array.from(new Set(matches));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `{{...}}` 단일 바인딩 조건식을 주어진 자유변수 스코프로 실제 평가합니다.
|
||||||
|
*
|
||||||
|
* 문자열 포함/동일성 단언(`toContain`/`toBe`)은 조건을 잘못 고쳐도 기대 문자열을 함께
|
||||||
|
* 고치면 통과하므로 회귀를 잡지 못한다. 식을 그대로 실행해 결과 boolean 을 본다.
|
||||||
|
*
|
||||||
|
* @param expr `{{ ... }}` 형태의 조건식(감싸지 않은 식도 허용)
|
||||||
|
* @param scope 식이 참조하는 자유변수 (예: `{ _global: {...} }`)
|
||||||
|
* @returns 평가 결과의 boolean 캐스팅
|
||||||
|
*/
|
||||||
|
export function evalBinding(expr: string, scope: Record<string, unknown>): 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])));
|
||||||
|
}
|
||||||
+339
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+121
@@ -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<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
||||||
|
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<string, unknown>).extension_point).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`배너·안내박스는 target_id=${targetId} 에 prepend_child 로 주입된다`, () => {
|
||||||
|
const injections = (overlay as { injections?: Array<Record<string, unknown>> }).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));
|
||||||
|
});
|
||||||
|
});
|
||||||
+841
@@ -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<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[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<string, unknown>): 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<string, Record<string, unknown> | 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<string, unknown> | 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<string, unknown>).extension_point).toBeUndefined();
|
||||||
|
for (const [label, o] of [['core', coreOverlay], ['board', boardOverlay], ['ecommerce', ecommerceOverlay]] as const) {
|
||||||
|
expect((o as Record<string, unknown>).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<string, unknown>).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<string, unknown>).target_layout).toBeUndefined();
|
||||||
|
expect((footerActions as Record<string, unknown>).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<Record<string, unknown>> }).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<string, unknown> })?.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<string, any>).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<string, { primary: string; detail: string | null }> = {
|
||||||
|
'행 없음': { 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<string, unknown> | 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>; bz_sms_modal: Record<string, unknown> };
|
||||||
|
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<string, unknown>).templateContent)).toContain("tpl.channel === 'alimtalk'");
|
||||||
|
expect(String((seed.bz_tpl_modal.content as Record<string, unknown>).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<string, unknown>) => ({
|
||||||
|
_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<string, unknown> }).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<string, unknown>), `${label}: 폼`).toBe(false);
|
||||||
|
expect(evalBinding(String(locked.if), scope as Record<string, unknown>), `${label}: 잠금 요약`).toBe(false);
|
||||||
|
expect(evalBinding(String(loadingNotice.if), scope as Record<string, unknown>), `${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<string, unknown>;
|
||||||
|
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<string, (s: Record<string, unknown>) => 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<string, unknown>) => ({ _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<string, unknown>, 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<string, string> }).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<string, string> }).body.content).toBeTruthy();
|
||||||
|
expect((steps[1].params as { body: Record<string, string> }).body.content).toBeTruthy();
|
||||||
|
expect((steps[2].params as { body: Record<string, string> }).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<string, string> }).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<string, string> }).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<string, unknown>;
|
||||||
|
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<string, string>)['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<string, unknown>)?.bz_tpl_modal);
|
||||||
|
expect(seeds.length).toBeGreaterThan(0);
|
||||||
|
for (const s of seeds) {
|
||||||
|
expect(((s.params as Record<string, unknown>).bz_tpl_modal as Record<string, unknown>).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<Record<string, any>>) {
|
||||||
|
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<Record<string, any>>) {
|
||||||
|
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<string, unknown>): boolean =>
|
||||||
|
evalBinding(expr, { _global: { bz_tpl_modal: modal, bz_tpl_upload: {} } });
|
||||||
|
|
||||||
|
const conditionalBlocks = (): Record<string, string> => {
|
||||||
|
const found: Record<string, string> = {};
|
||||||
|
const walk = (node: unknown): void => {
|
||||||
|
if (Array.isArray(node)) { node.forEach(walk); return; }
|
||||||
|
if (!node || typeof node !== 'object') return;
|
||||||
|
const rec = node as Record<string, unknown>;
|
||||||
|
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<string, unknown> => {
|
||||||
|
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<string, string[]> = {
|
||||||
|
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<string>();
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
+435
@@ -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<typeof createLayoutTest>): 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<string, any>).settings.fields.password.label).toBe('비즈뿌리오 모듈 비밀번호');
|
||||||
|
expect((en as Record<string, any>).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<string, unknown>, path: string): unknown =>
|
||||||
|
path.split('.').reduce<unknown>((acc, seg) => {
|
||||||
|
if (acc && typeof acc === 'object') {
|
||||||
|
return (acc as Record<string, unknown>)[seg];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, dict);
|
||||||
|
|
||||||
|
for (const raw of keys) {
|
||||||
|
// "$t:sirsoft-message_bizppurio.settings.title" → "settings.title"
|
||||||
|
const path = raw.replace('$t:sirsoft-message_bizppurio.', '');
|
||||||
|
expect(resolve(ko, path), `ko 누락: ${path}`).toBeTruthy();
|
||||||
|
expect(resolve(en, path), `en 누락: ${path}`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>
|
||||||
|
| 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<string, unknown> | null;
|
||||||
|
const manageView = findById(layout, 'templates_manage_view') as Record<string, unknown> | 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}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
+566
@@ -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<Record<string, unknown>> }).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<string, unknown>;
|
||||||
|
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<string, unknown>)?.['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<string> => {
|
||||||
|
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<string, Record<string, unknown>> = {
|
||||||
|
'행만 있고 내용 없음': { 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<string, unknown>): 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<string, unknown>): 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<Record<string, any>>) {
|
||||||
|
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<Record<string, any>>) {
|
||||||
|
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<string, unknown>)?.bz_tpl_modal);
|
||||||
|
expect(seeds.length).toBeGreaterThan(0);
|
||||||
|
for (const s of seeds) {
|
||||||
|
expect(((s.params as Record<string, unknown>).bz_tpl_modal as Record<string, unknown>).request_comment).toBe('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('라벨·placeholder·안내 키가 ko/en 에 존재한다', () => {
|
||||||
|
const pick = (json: unknown, key: string): unknown => key.split('.')
|
||||||
|
.reduce<unknown>((acc, seg) => (acc as Record<string, unknown>)?.[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<string, unknown>): 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<string, { props?: { className?: string } }> | 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<string, unknown>)?.['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');
|
||||||
|
});
|
||||||
|
});
|
||||||
+203
@@ -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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
// 관리 화면 모달: 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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<HTMLTextAreaElement | HTMLInputElement>(
|
||||||
|
`textarea[name="${target}"], input[name="${target}"]`,
|
||||||
|
);
|
||||||
|
const domValue = field?.value ?? '';
|
||||||
|
// DOM 값과 상태 값이 동일할 때만 커서 위치를 신뢰(동기화된 경우). 다르면 끝에 붙인다.
|
||||||
|
const inSync = domValue === current;
|
||||||
|
const start = inSync ? (field?.selectionStart ?? current.length) : current.length;
|
||||||
|
const end = inSync ? (field?.selectionEnd ?? current.length) : current.length;
|
||||||
|
|
||||||
|
const next = current.slice(0, start) + token + current.slice(end);
|
||||||
|
|
||||||
|
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<string, any>, path: string): unknown {
|
||||||
|
return path.split('.').reduce<any>((acc, key) => (acc == null ? undefined : acc[key]), obj);
|
||||||
|
}
|
||||||
+292
@@ -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<string, string> {
|
||||||
|
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<string, unknown>) => 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<string, any>) | 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<string, any>, path: string): unknown {
|
||||||
|
return path.split('.').reduce<any>((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<void> {
|
||||||
|
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<string, unknown>) => void,
|
||||||
|
pathStatus: string,
|
||||||
|
pathUrl: string,
|
||||||
|
pathName: string,
|
||||||
|
message: string,
|
||||||
|
): void {
|
||||||
|
setState({
|
||||||
|
[`${pathStatus}.uploading`]: false,
|
||||||
|
[`${pathStatus}.error`]: message,
|
||||||
|
[pathUrl]: '',
|
||||||
|
[pathName]: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* sirsoft-message_bizppurio 플러그인 프론트 타입 정의.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 액션 컨텍스트 인터페이스.
|
||||||
|
*
|
||||||
|
* ActionDispatcher 가 커스텀 핸들러 실행 시 전달하는 컨텍스트다.
|
||||||
|
*/
|
||||||
|
export interface ActionContext {
|
||||||
|
/** 현재 로컬 상태 가져오기 */
|
||||||
|
getLocalState?: () => Record<string, any>;
|
||||||
|
/** 로컬 상태 업데이트 */
|
||||||
|
setLocalState?: (updates: Record<string, any>) => void;
|
||||||
|
/** 이벤트 객체 */
|
||||||
|
event?: Event;
|
||||||
|
/** 데이터 컨텍스트 */
|
||||||
|
dataContext?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 핸들러 액션 객체(공통).
|
||||||
|
*/
|
||||||
|
export interface ActionWithParams {
|
||||||
|
handler: string;
|
||||||
|
params?: Record<string, any>;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "원클릭 결제"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5274
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\MessageBizppurio\Concerns;
|
||||||
|
|
||||||
|
use App\Helpers\ResponseHelper;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카카오 관리 API(kapi) 위임 액션을 공통 래핑하는 트레이트.
|
||||||
|
*
|
||||||
|
* kapi 실패(자격증명 미설정·반려·차단 등)는 BizppurioApiException 으로 전달되므로, 이를 catch
|
||||||
|
* 하여 카카오가 준 실패 사유(message)와 결과 코드를 그대로 422 로 반환한다. 운영자가 반려/차단
|
||||||
|
* 사유를 화면에서 바로 확인할 수 있게 한다. 알림톡 템플릿 관리(Phase 5)와 연동(Phase 6)
|
||||||
|
* 컨트롤러가 공유한다.
|
||||||
|
*
|
||||||
|
* errors 키는 `bizppurio_message` 로 고정한다 — 이 플러그인의 다른 kapi 경로
|
||||||
|
* (BizppurioTemplateController·TokenCheckController)와 화면 소비처, API 문서가 모두 그 키를
|
||||||
|
* 읽는다. 이 트레이트만 다른 키를 쓰면 카테고리·발신프로필 조회 실패에서만 사유 원문이
|
||||||
|
* 버려지고 고정 문구만 노출된다.
|
||||||
|
*/
|
||||||
|
trait GuardsKakaoRequests
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* kapi 위임 콜백을 실행하고 실패 시 카카오 사유를 422 로 반환합니다.
|
||||||
|
*
|
||||||
|
* @param callable():JsonResponse $callback kapi 위임 액션
|
||||||
|
* @return JsonResponse 성공 응답 또는 카카오 사유가 담긴 422
|
||||||
|
*/
|
||||||
|
private function guard(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(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\MessageBizppurio\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioDispatch;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* webhook replay 방어 — 동일 발송 이력에 리포트가 중복 도착하면 멱등 처리한다.
|
||||||
|
*
|
||||||
|
* 비즈뿌리오 URL PUSH 는 동일 리포트를 재전송할 수 있다. 발송 이력의 `reported_at`
|
||||||
|
* 이 이미 채워져 있으면 리포트를 이미 반영한 것이므로, 상태를 다시 뒤집거나 잔액부족
|
||||||
|
* 자체 알림을 재발송하지 않도록 조기 리턴한다(계획서 D13).
|
||||||
|
*/
|
||||||
|
trait PreventsReplayWebhook
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 이 이력이 이미 리포트를 반영했는지(=replay) 확인합니다.
|
||||||
|
*
|
||||||
|
* @param BizppurioDispatch $dispatch refkey 로 매칭된 발송 이력
|
||||||
|
* @return bool true 면 중복 리포트 — 멱등 응답으로 처리해야 함
|
||||||
|
*/
|
||||||
|
protected function wasAlreadyReported(BizppurioDispatch $dispatch): bool
|
||||||
|
{
|
||||||
|
return $dispatch->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\MessageBizppurio\Console;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTemplateService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림톡 템플릿 검수 상태 동기화 커맨드 (#597 §3.4).
|
||||||
|
*
|
||||||
|
* 검수중(requested) 행이 있을 때만 카카오 관리 API 를 호출해 상태를 일괄 대조한다
|
||||||
|
* (senderKey 별 template/list 1회 — 행별 detail 호출 없음, 레이트리밋 보호). 반려로
|
||||||
|
* 전이한 행만 사유(comments) 확보를 위해 detail 을 추가 호출한다.
|
||||||
|
*
|
||||||
|
* plugin.php::getSchedules() 가 30분 주기로 등록하며, 화면의 수동 [새로고침]이 동등
|
||||||
|
* 기능을 제공하므로 cron 미가동 환경에서도 승인 확인이 가능하다.
|
||||||
|
*/
|
||||||
|
class SyncTemplateStatusCommand extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 커맨드 시그니처
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'bizppurio:sync-template-status';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커맨드 설명
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = '검수중인 비즈뿌리오 알림톡 템플릿의 카카오 검수 상태를 동기화합니다';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커맨드를 실행합니다.
|
||||||
|
*
|
||||||
|
* @param BizppurioTemplateService $service 템플릿 라이프사이클 서비스
|
||||||
|
* @return int 종료 코드 (항상 SUCCESS — 조회 실패는 서비스가 로그로 남기고 다음 주기에 재시도)
|
||||||
|
*/
|
||||||
|
public function handle(BizppurioTemplateService $service): int
|
||||||
|
{
|
||||||
|
$result = $service->syncRequested();
|
||||||
|
|
||||||
|
if ($result['checked'] === 0) {
|
||||||
|
$this->info('검수중(requested) 템플릿이 없어 카카오 조회를 건너뜁니다.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'검수중 템플릿 %d건 점검, %d건 상태 전이.',
|
||||||
|
$result['checked'],
|
||||||
|
$result['transitioned'],
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Helpers\ResponseHelper;
|
||||||
|
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Concerns\GuardsKakaoRequests;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Services\AlimtalkTemplateService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림톡 작성 모달 참조 조회 컨트롤러 (#597).
|
||||||
|
*
|
||||||
|
* 알림톡 템플릿 작성 모달의 발신프로필 셀렉트·카테고리 셀렉트가 소비하는 kapi 조회를
|
||||||
|
* 위임한다. 템플릿 목록/상세의 실시간 조회 화면(구 Phase 5)은 DB 기반 라이프사이클
|
||||||
|
* (BizppurioTemplateController)로 대체되어 제거됐다.
|
||||||
|
*
|
||||||
|
* 권한(라우트 미들웨어): 조회(categories/profiles) = messaging.view
|
||||||
|
*
|
||||||
|
* kapi 실패는 BizppurioApiException 으로 전달되므로, guard 로 감싸 카카오가 준
|
||||||
|
* 실패 사유(message)를 그대로 422 로 반환한다.
|
||||||
|
*/
|
||||||
|
class AlimtalkTemplateController extends AdminBaseController
|
||||||
|
{
|
||||||
|
use GuardsKakaoRequests;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param AlimtalkTemplateService $service 발신프로필·카테고리 조회 서비스
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly AlimtalkTemplateService $service,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 템플릿 등록에 사용할 카테고리 전체를 조회합니다.
|
||||||
|
*
|
||||||
|
* @return JsonResponse data.categories 에 대분류·소분류 목록
|
||||||
|
*/
|
||||||
|
public function categories(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->guard(fn () => ResponseHelper::success('messages.success', [
|
||||||
|
'categories' => $this->service->categories(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 발신프로필(사용중) 정보를 조회합니다.
|
||||||
|
*
|
||||||
|
* @return JsonResponse data.profiles 에 발신프로필 상태 정보
|
||||||
|
*/
|
||||||
|
public function profiles(): JsonResponse
|
||||||
|
{
|
||||||
|
return $this->guard(fn () => ResponseHelper::success('messages.success', [
|
||||||
|
'profiles' => $this->service->senderProfiles(),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
+382
@@ -0,0 +1,382 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Sirsoft\MessageBizppurio\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Helpers\ResponseHelper;
|
||||||
|
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Enums\BizppurioTemplateStatus;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioApiException;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Exceptions\BizppurioTemplateStateException;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateImageRequest;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\BizppurioTemplateListRequest;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\RequestBizppurioInspectionRequest;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\StoreBizppurioTemplateRequest;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioDeliveryRequest;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Http\Requests\UpdateBizppurioTemplateRequest;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Models\BizppurioTemplate;
|
||||||
|
use Plugins\Sirsoft\MessageBizppurio\Services\BizppurioTemplateService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비즈뿌리오 알림 템플릿 라이프사이클 컨트롤러 (#597 §3.2).
|
||||||
|
*
|
||||||
|
* 시스템 등록(draft) → 검수 신청(requested) → 승인(approved) 후 발송 활성화의 전 흐름을
|
||||||
|
* 담당한다. 발송 판정은 DB 가 유일한 근거이며(실시간 조회 폐지), 카카오와의 상태 정합은
|
||||||
|
* 스케줄러(bizppurio:sync-template-status)와 수동 sync 가 유지한다.
|
||||||
|
*
|
||||||
|
* 권한(라우트 미들웨어):
|
||||||
|
* - 조회(index/map/show): sirsoft-message_bizppurio.messaging.view
|
||||||
|
* - 변경(store/update/request/cancel/sync/image/destroy): sirsoft-message_bizppurio.messaging.manage
|
||||||
|
*
|
||||||
|
* 실패 응답 규약:
|
||||||
|
* - kapi 실패(BizppurioApiException) → 422 + errors.bizppurio_message(카카오 사유 원문)·errors.result_code
|
||||||
|
* - 상태 전이 위반(BizppurioTemplateStateException) → 422 + 예외의 메시지 키 해석
|
||||||
|
*/
|
||||||
|
class BizppurioTemplateController extends AdminBaseController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param BizppurioTemplateService $service 템플릿 라이프사이클 서비스
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly BizppurioTemplateService $service,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 템플릿 DB 목록을 조회합니다 (관리 화면 — 알림 정의 라벨·소속 조인).
|
||||||
|
*
|
||||||
|
* 쿼리: status(우리 상태 enum)·search(알림 유형/이름)·page·per_page
|
||||||
|
*
|
||||||
|
* @param BizppurioTemplateListRequest $request 검증된 목록 필터
|
||||||
|
* @return JsonResponse data 에 templates·pagination
|
||||||
|
*/
|
||||||
|
public function index(BizppurioTemplateListRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$paginator = $this->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<string, mixed> 직렬화된 행
|
||||||
|
*/
|
||||||
|
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<string, mixed> 직렬화된 상세
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user