cache->remember( $this->getCacheKey($type, $channel), fn () => $this->templateRepository->getActiveByTypeAndChannel($type, $channel), $this->getCacheTtl(), ['notification'] ); } /** * 특정 알림 정의의 모든 템플릿 조회. * * @param int $definitionId * @return Collection */ public function getByDefinitionId(int $definitionId): Collection { return $this->templateRepository->getByDefinitionId($definitionId); } /** * 템플릿 수정. * * @param NotificationTemplate $template * @param array $data * @param int|null $userId * @return NotificationTemplate */ public function updateTemplate(NotificationTemplate $template, array $data, ?int $userId = null): NotificationTemplate { $snapshot = $template->toArray(); HookManager::doAction('core.notification_template.before_update', $template, $data); $data = HookManager::applyFilters( 'core.notification_template.filter_update_data', $data, $template ); if ($userId) { $data['updated_by'] = $userId; } // 사용자가 편집한 템플릿은 더 이상 기본 상태가 아님 $data['is_default'] = false; $updated = $this->templateRepository->update($template, $data); $definition = $updated->definition; if ($definition) { // 소속 정의도 "커스터마이징됨" 상태로 전환 (리셋 버튼 노출 근거) if ($definition->is_default) { $this->definitionRepository->update($definition, ['is_default' => false]); } $this->invalidateCache($definition->type, $updated->channel); } HookManager::doAction('core.notification_template.after_update', $updated, $data, $snapshot); return $updated; } /** * 활성/비활성 토글. * * @param NotificationTemplate $template * @return NotificationTemplate */ public function toggleActive(NotificationTemplate $template): NotificationTemplate { HookManager::doAction('core.notification_template.before_toggle_active', $template); $updated = $this->templateRepository->update($template, [ 'is_active' => ! $template->is_active, ]); $definition = $updated->definition; if ($definition) { $this->invalidateCache($definition->type, $updated->channel); } HookManager::doAction('core.notification_template.after_toggle_active', $updated); return $updated; } /** * 기본값으로 복원. * * @param NotificationTemplate $template * @param array $defaultData * @return NotificationTemplate */ public function resetToDefault(NotificationTemplate $template, array $defaultData): NotificationTemplate { // 복원은 사용자 수정이 아니므로 seeding 컨텍스트로 감싸 HasUserOverrides 의 // updating 자동 기록을 비활성화한다. 이 가드가 없으면 복원되는 trackable 필드 // (subject/body/click_url/recipients)가 dirty 로 감지되어 user_overrides 가 // 즉시 재기록되고, 복원이 무효화된다. app()->instance('user_overrides.seeding', true); try { $updated = $this->templateRepository->update($template, array_merge($defaultData, [ 'is_default' => true, 'user_overrides' => null, ])); } finally { app()->forgetInstance('user_overrides.seeding'); } $definition = $updated->definition; if ($definition) { $this->invalidateCache($definition->type, $updated->channel); } return $updated; } /** * 시더에서 기본 템플릿 데이터를 조회합니다. * * @param string $type 알림 정의 타입 * @param string $channel 채널명 * @return array 기본 템플릿 데이터 (subject, body) */ public function getDefaultTemplateData(string $type, string $channel): array { // config/core.php 의 notification_definitions 블록을 SSoT 로 읽어 type/extension_* 정규화 $coreDefinitions = config('core.notification_definitions', []); $definitions = []; foreach ($coreDefinitions as $coreType => $data) { $definitions[] = array_merge($data, [ 'type' => $coreType, 'extension_type' => 'core', 'extension_identifier' => 'core', ]); } // 확장(모듈/플러그인)이 자신의 기본 정의를 추가할 수 있도록 필터 훅 노출 $definitions = HookManager::applyFilters( 'core.notification.filter_default_definitions', $definitions, ['type' => $type, 'channel' => $channel] ); // 활성 언어팩 seed 로케일 병합 — 시딩(seed.notifications.translations)과 같은 SSoT. // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의 // ko/en 만으로 대체해 영구 소실시킨다 (2026-08-23 실사례 — #597 보완 실측). $definitions = $this->mergeLanguagePackLocalesIntoDefaults($definitions); foreach ($definitions as $def) { if ($def['type'] === $type) { foreach ($def['templates'] as $tpl) { if ($tpl['channel'] === $channel) { return [ 'subject' => $tpl['subject'] ?? null, 'body' => $tpl['body'], 'click_url' => $tpl['click_url'] ?? null, 'recipients' => $tpl['recipients'] ?? null, ]; } } } } return []; } /** * 기본 정의 배열에 활성 언어팩 seed 로케일을 병합합니다. * * 코어 소유 정의는 활성 코어 팩(injectNotifications), 확장 소유 정의는 * 해당 확장의 활성 모듈/플러그인 팩(injectExtensionNotifications)이 공급한다. * definition type 은 전역 고유(UNIQUE)이므로 확장별 호출이 다른 확장의 * 정의에 오병합될 여지는 없다. * * @param array> $definitions type 필드를 포함한 기본 정의 배열 * @return array> 활성 팩 로케일이 병합된 정의 배열 */ private function mergeLanguagePackLocalesIntoDefaults(array $definitions): array { $definitions = $this->seedInjector->injectNotifications($definitions); $extensionIdentifiers = []; foreach ($definitions as $def) { $identifier = is_array($def) ? ($def['extension_identifier'] ?? null) : null; if (is_string($identifier) && $identifier !== '' && $identifier !== 'core') { $extensionIdentifiers[$identifier] = true; } } foreach (array_keys($extensionIdentifiers) as $identifier) { $definitions = $this->seedInjector->injectExtensionNotifications($definitions, $identifier); } return $definitions; } /** * 미리보기용 렌더링. * * definition_id가 제공되면 해당 정의의 변수 메타데이터를 조회하여 * 샘플 값으로 치환합니다. * * @param array $data * @return array */ public function getPreview(array $data): array { $locale = $data['locale'] ?? app()->getLocale(); $subject = $data['subject'][$locale] ?? ''; $body = $data['body'][$locale] ?? ''; $replacements = []; // definition_id로 변수 메타데이터 조회 if (isset($data['definition_id'])) { $definition = $this->definitionRepository->findById((int) $data['definition_id']); if ($definition) { foreach ($definition->variables ?? [] as $var) { $key = $var['key'] ?? ''; if ($key !== '') { $description = $var['description'] ?? $key; $replacements['{'.$key.'}'] = '['.$description.']'; } } } } // 명시적으로 전달된 variables가 있으면 덮어쓰기 foreach ($data['variables'] ?? [] as $key => $value) { $replacements['{'.$key.'}'] = (string) $value; } return [ 'subject' => strtr($subject, $replacements), 'body' => strtr($body, $replacements), ]; } /** * 특정 타입+채널의 캐시 무효화. * * @param string $type * @param string $channel * @return void */ public function invalidateCache(string $type, string $channel): void { $this->cache->forget($this->getCacheKey($type, $channel)); } /** * 캐시 키 생성. * * @param string $type * @param string $channel * @return string */ private function getCacheKey(string $type, string $channel): string { return $this->cachePrefix.$type.'.'.$channel; } }