Files
Gnuboard7/app/Services/IdentityMessageTemplateService.php
T
HeuJung e7fa1b5535 feat(message_bizppurio): 알림 템플릿 편집 통합·검수 의견·관리탭 개선 및 언어팩 번역 보존 결함군 수정
비즈뿌리오 알림톡 UX 개편 후속:
- 코어/게시판/이커머스 알림 템플릿 [편집] 모달에 확장 채널 편집 영역
 (extension_point 2종 + hidden_template_editor 채널 메타)을 신설하고,
 알림톡 템플릿·문자(SMS)·수신자 규칙을 한 창에서 통합 저장하도록 개정
 (행 하단은 상태 요약 전용, 승인 여부 2단 배지)
- 검수 신청에 검수자 전달 의견(comment) 동봉 — kapi request 로만 전달,
 행에는 저장하지 않음 (FormRequest 신설 + API 문서 백필)
- 관리 탭 모바일 카드뷰·도구줄 코어 관례 통일, 사이트맵 알림 정의
 다국어 라벨 결측 수정 + 코어 정의 라벨 패리티 테스트 신설

언어팩 번역 소실·보존 결함군 (보완 실측 → 커밋 전 점검에서 연쇄 확정,
전부 실패 테스트 선행 후 수정):
- [기본값 복원]이 활성 언어팩 번역(ja)을 영구 소실 → 복원 기본값에
 시딩과 같은 주입기(SSoT)로 팩 로케일 병합 (notification·identity)
- 코어 알림/본인인증 시더의 언어팩 주입 전면 불능 → 주입기가 연관/리스트
 양형태 수용 + identity 시더의 config 복합 키 보존
- 실패한 언어팩 업데이트가 active 팩을 installed 로 방치 → 상태 복원 확장
- 병합·제거의 사용자 수정 보존 판정 사문(연관 전제 isset) → dot-path 판정
 교정, 팩 주입 저장의 user_overrides 오염 차단(시딩 바인딩), 템플릿
 2모델 translatableTrackableFields 선언(beta.4 설계 누락), 같은 프로세스
 활성화 시 시더 번역 필터 stale(캡처 인스턴스 캐시) 교정
- 7.0.9 업그레이드 스텝 신설 — 활성 팩 seed 재동기화로 기설치본의 소실
 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
2026-08-24 14:51:37 +09:00

364 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
use App\Extension\HookManager;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
use App\Services\LanguagePack\LanguagePackSeedInjector;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Auth;
/**
* IDV 메시지 템플릿 서비스.
*
* 알림 시스템(NotificationTemplateService)과 분리된 IDV 전용 서비스.
* (definition_id, channel) 키로 캐싱하며, 변수 미리보기/기본값 복원 기능을 제공합니다.
*/
class IdentityMessageTemplateService
{
/**
* 캐시 키 접두사.
*/
protected string $cachePrefix = 'identity_message.template.';
/**
* 캐시 태그 (DefinitionService 와 공유).
*/
protected string $cacheTag = 'identity_message';
/**
* @param IdentityMessageTemplateRepositoryInterface $repository
* @param IdentityMessageDefinitionService $definitionService
* @param CacheInterface $cache
*/
public function __construct(
private readonly IdentityMessageTemplateRepositoryInterface $repository,
private readonly IdentityMessageDefinitionService $definitionService,
private readonly CacheInterface $cache,
private readonly LanguagePackSeedInjector $seedInjector,
) {}
/**
* 캐시 TTL (초).
*
* @return int
*/
protected function getCacheTtl(): int
{
$value = g7_core_settings('cache.notification_ttl', 3600);
return $value !== null ? (int) $value : 3600;
}
/**
* (definition_id, channel) 활성 템플릿 조회 (캐싱).
*
* @param int $definitionId
* @param string $channel
* @return IdentityMessageTemplate|null
*/
public function resolve(int $definitionId, string $channel): ?IdentityMessageTemplate
{
return $this->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<int, array{key: string, description: string}>
*/
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;
}
}