cache->remember( $this->getCacheKey($definitionId, $channel), fn () => $this->repository->getActiveByDefinitionAndChannel($definitionId, $channel), $this->getCacheTtl(), [$this->cacheTag] ); } /** * ID 로 템플릿을 조회하며, 없으면 예외를 발생시킵니다. * * 컨트롤러가 모델을 직접 조회하지 않도록 하는 Service 경유 접근자입니다 * (Controller → Service → Repository 계층 규정). * * @param int $id 템플릿 ID * @return IdentityMessageTemplate 템플릿 모델 * * @throws ModelNotFoundException 템플릿이 없는 경우 */ public function findOrFailById(int $id): IdentityMessageTemplate { $template = $this->repository->findById($id); if (! $template) { throw (new ModelNotFoundException) ->setModel(IdentityMessageTemplate::class, [$id]); } return $template; } /** * 템플릿 수정. * * @param IdentityMessageTemplate $template * @param array $data * @return IdentityMessageTemplate */ public function updateTemplate(IdentityMessageTemplate $template, array $data): IdentityMessageTemplate { HookManager::doAction('core.identity.message_template.before_update', $template, $data); $data = HookManager::applyFilters( 'core.identity.message_template.filter_update_data', $data, $template ); if (! array_key_exists('updated_by', $data) && Auth::id()) { $data['updated_by'] = Auth::id(); } $data['is_default'] = false; $updated = $this->repository->update($template, $data); if ($updated->definition && $updated->definition->is_default) { $this->definitionService->updateDefinition($updated->definition, ['is_default' => false]); } $this->definitionService->invalidateAllCache(); HookManager::doAction('core.identity.message_template.after_update', $updated, $data); return $updated; } /** * 활성/비활성 토글. * * @param IdentityMessageTemplate $template * @return IdentityMessageTemplate */ public function toggleActive(IdentityMessageTemplate $template): IdentityMessageTemplate { HookManager::doAction('core.identity.message_template.before_toggle_active', $template); $updated = $this->repository->update($template, [ 'is_active' => ! $template->is_active, ]); $this->definitionService->invalidateAllCache(); HookManager::doAction('core.identity.message_template.after_toggle_active', $updated); return $updated; } /** * 템플릿을 시더 기본값으로 복원합니다. * * @param IdentityMessageTemplate $template * @return IdentityMessageTemplate */ public function resetToDefault(IdentityMessageTemplate $template): IdentityMessageTemplate { HookManager::doAction('core.identity.message_template.before_reset', $template); $defaultData = $this->getDefaultTemplateData($template); if ($defaultData === null) { HookManager::doAction('core.identity.message_template.reset_no_default', $template); return $template; } // user_overrides 추적 회피 — reset 은 의도적 복원이므로 trackable 필드 변경을 // user_overrides 에 다시 추가하면 안 됨. HasUserOverrides 의 시더 플래그 재사용. $previousFlag = app()->bound('user_overrides.seeding') ? app('user_overrides.seeding') : null; app()->instance('user_overrides.seeding', true); try { $updated = $this->repository->update($template, [ 'subject' => $defaultData['subject'] ?? null, 'body' => $defaultData['body'] ?? '', 'is_active' => $defaultData['is_active'] ?? true, 'is_default' => true, 'user_overrides' => [], ]); } finally { if ($previousFlag === null) { app()->forgetInstance('user_overrides.seeding'); } else { app()->instance('user_overrides.seeding', $previousFlag); } } // 템플릿 편집 시 updateTemplate 이 definition.is_default 를 false 로 내렸으므로, // 시드 정의 복원 시 definition 플래그도 함께 true 로 되돌린다 ('기본' 배지/reset 버튼 노출 조건 정상화). if ($updated->definition instanceof IdentityMessageDefinition) { $this->definitionService->markAsDefault($updated->definition); } $this->definitionService->invalidateAllCache(); HookManager::doAction('core.identity.message_template.after_reset', $updated); return $updated; } /** * 변수 치환 미리보기. * * @param IdentityMessageTemplate $template * @param array $data * @param string|null $locale * @return array{subject: string, body: string} */ public function getPreview(IdentityMessageTemplate $template, array $data = [], ?string $locale = null): array { return $template->replaceVariables($data, $locale); } /** * 시더가 정의한 기본 템플릿 데이터를 반환합니다. * * @param IdentityMessageTemplate $template * @return array|null */ protected function getDefaultTemplateData(IdentityMessageTemplate $template): ?array { $defaultDefinition = $this->getDefaultDefinitionData($template->definition); if ($defaultDefinition === null) { return null; } foreach ($defaultDefinition['templates'] ?? [] as $defaultTemplate) { if (($defaultTemplate['channel'] ?? null) === $template->channel) { return $defaultTemplate; } } return null; } /** * 정의가 시더 기본 정의(시드 정의)에 매칭되면 해당 기본 정의 데이터를 반환합니다. * * 운영자가 추가한 정의(admin definition)는 어떤 기본 정의에도 매칭되지 않아 null 을 반환합니다. * * @param IdentityMessageDefinition|null $definition * @return array|null */ protected function getDefaultDefinitionData(?IdentityMessageDefinition $definition): ?array { if (! $definition instanceof IdentityMessageDefinition) { return null; } foreach ($this->collectDefaultDefinitions() as $defaultDefinition) { if ($this->matchesDefinition($defaultDefinition, $definition)) { return $defaultDefinition; } } return null; } /** * 코어 + 확장의 기본 정의를 모두 수집합니다 (filter 훅 통합). * * 코어 정의는 `config/core.php` 의 `identity_messages` 블록을 SSoT 로 직독합니다. * 확장(모듈/플러그인)은 `core.identity.filter_default_message_definitions` 훅으로 자체 정의를 추가합니다. * * @return array */ protected function collectDefaultDefinitions(): array { $coreDefinitions = $this->loadCoreMessageDefinitions(); $definitions = HookManager::applyFilters( 'core.identity.filter_default_message_definitions', $coreDefinitions, [] ); // 활성 언어팩 seed 로케일 병합 — 시딩(seed.identity_messages.translations)과 같은 SSoT. // 이 병합이 없으면 [기본값 복원]이 팩이 주입해 둔 로케일(ja 등)을 config 의 // ko/en 만으로 대체해 영구 소실시킨다 (notification 쪽 실사례의 동형 — #597 보완 실측). // injectIdentityMessages 는 string 키('mail.purpose.signup' 등)로 seed 를 매칭하므로 // loadCoreMessageDefinitions 가 config 키를 보존해 넘긴다. 확장 리스너가 정수 키로 // append 한 항목은 seed 에 매칭되지 않아 그대로 통과한다. return $this->seedInjector->injectIdentityMessages($definitions); } /** * config/core.php 의 identity_messages 블록을 정규화하여 반환합니다. * * `'variables' => '__common__'` 마커는 commonVariables() 로 expand 하며, * extension_type/extension_identifier 를 'core' 로 자동 주입합니다. * * @return array */ protected function loadCoreMessageDefinitions(): array { $messages = config('core.identity_messages', []); $common = $this->commonVariables(); $result = []; foreach ($messages as $key => $data) { if (($data['variables'] ?? null) === '__common__') { $data['variables'] = $common; } $data['extension_type'] = 'core'; $data['extension_identifier'] = 'core'; // config 키('mail.provider_default' 등)를 보존 — 언어팩 seed 매칭 키와 동일 (collectDefaultDefinitions 참조) $result[$key] = $data; } return $result; } /** * 표준 변수 메타데이터 (모든 mail 정의 공통). * * config/core.php 의 identity_messages 블록에서 `'variables' => '__common__'` 마커로 참조됩니다. * * @return array */ protected function commonVariables(): array { return [ ['key' => 'code', 'description' => '인증 코드 (text_code 흐름)'], ['key' => 'action_url', 'description' => '검증 링크 URL (link 흐름)'], ['key' => 'expire_minutes', 'description' => '만료까지 남은 분'], ['key' => 'purpose_label', 'description' => '인증 목적 라벨 (다국어)'], ['key' => 'app_name', 'description' => '사이트명'], ['key' => 'site_url', 'description' => '사이트 URL'], ['key' => 'recipient_email', 'description' => '수신자 이메일'], ]; } /** * 시더 데이터가 특정 정의와 매칭되는지 확인합니다. * * @param array $defaultDefinition * @param IdentityMessageDefinition $definition * @return bool */ protected function matchesDefinition(array $defaultDefinition, IdentityMessageDefinition $definition): bool { return ($defaultDefinition['provider_id'] ?? null) === $definition->provider_id && ($defaultDefinition['scope_type'] ?? null) === $definition->scope_type->value && ((string) ($defaultDefinition['scope_value'] ?? '')) === (string) $definition->scope_value; } /** * 캐시 키 생성. * * @param int $definitionId * @param string $channel * @return string */ private function getCacheKey(int $definitionId, string $channel): string { return $this->cachePrefix.$definitionId.'.'.$channel; } }