공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.
- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
(전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
(ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
353 lines
12 KiB
PHP
353 lines
12 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 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,
|
|
) {}
|
|
|
|
/**
|
|
* 캐시 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();
|
|
|
|
return HookManager::applyFilters(
|
|
'core.identity.filter_default_message_definitions',
|
|
$coreDefinitions,
|
|
[]
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 $data) {
|
|
if (($data['variables'] ?? null) === '__common__') {
|
|
$data['variables'] = $common;
|
|
}
|
|
$data['extension_type'] = 'core';
|
|
$data['extension_identifier'] = 'core';
|
|
$result[] = $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;
|
|
}
|
|
}
|