비즈뿌리오 알림톡 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 재동기화로 기설치본의 소실 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
752 lines
31 KiB
PHP
752 lines
31 KiB
PHP
<?php
|
||
|
||
namespace App\Services\LanguagePack;
|
||
|
||
use App\Enums\LanguagePackScope;
|
||
use App\Models\LanguagePack;
|
||
use Illuminate\Support\Facades\File;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* HookManager 필터 리스너 — 시드 단계에서 다국어 키를 병합 주입.
|
||
*
|
||
* 시더가 helper 위임 직전에 applyFilters('core.permissions.config' 등)를 호출하면 본 리스너가
|
||
* 활성 코어 언어팩의 seed/*.json 을 읽어 locale 키를 추가한 형태로 반환합니다.
|
||
* 실제 user_overrides 보존 판정은 SyncDatabaseTranslations 리스너에서 수행하며,
|
||
* 본 리스너는 시드 데이터 자체에 새 locale 키를 더하는 역할만 담당합니다.
|
||
*/
|
||
class LanguagePackSeedInjector
|
||
{
|
||
/**
|
||
* @param LanguagePackRegistry $registry 활성 언어팩 레지스트리
|
||
*/
|
||
public function __construct(
|
||
private readonly LanguagePackRegistry $registry,
|
||
) {}
|
||
|
||
/**
|
||
* core.permissions.config 필터 — config('core.permissions') 에 locale 키 병합.
|
||
*
|
||
* @param array<string, mixed> $config permission 설정
|
||
* @return array<string, mixed> locale 키가 보강된 설정
|
||
*/
|
||
public function injectCorePermissions(array $config): array
|
||
{
|
||
foreach ($this->extraCoreLocales() as $locale) {
|
||
$seed = $this->loadCoreSeed($locale, 'permissions');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
$config = $this->mergeIntoPermissionConfig($config, $seed, $locale);
|
||
}
|
||
|
||
return $config;
|
||
}
|
||
|
||
/**
|
||
* core.roles.config 필터 — config('core.roles') 에 locale 키 병합.
|
||
*
|
||
* @param array<int, mixed> $roles role 설정 배열
|
||
* @return array<int, mixed> locale 키가 보강된 설정
|
||
*/
|
||
public function injectCoreRoles(array $roles): array
|
||
{
|
||
foreach ($this->extraCoreLocales() as $locale) {
|
||
$seed = $this->loadCoreSeed($locale, 'roles');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
foreach ($roles as $idx => $role) {
|
||
$key = $role['identifier'] ?? null;
|
||
if (! $key || ! isset($seed[$key])) {
|
||
continue;
|
||
}
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($seed[$key][$field])) {
|
||
$roles[$idx][$field][$locale] = $seed[$key][$field];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $roles;
|
||
}
|
||
|
||
/**
|
||
* core.menus.config 필터 — config('core.menus') 에 locale 키 병합.
|
||
*
|
||
* @param array<int, mixed> $menus menu 설정
|
||
* @return array<int, mixed> locale 키가 보강된 설정
|
||
*/
|
||
public function injectCoreMenus(array $menus): array
|
||
{
|
||
foreach ($this->extraCoreLocales() as $locale) {
|
||
$seed = $this->loadCoreSeed($locale, 'menus');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
foreach ($menus as $idx => $menu) {
|
||
$slug = $menu['slug'] ?? null;
|
||
if (! $slug || ! isset($seed[$slug]['name'])) {
|
||
continue;
|
||
}
|
||
$menus[$idx]['name'][$locale] = $seed[$slug]['name'];
|
||
}
|
||
}
|
||
|
||
return $menus;
|
||
}
|
||
|
||
/**
|
||
* seed.notifications.translations 필터 — Definition × Template 3-tier 병합.
|
||
*
|
||
* @param array<int, mixed> $definitions 알림 정의 배열
|
||
* @return array<int, mixed> locale 키가 보강된 정의 배열
|
||
*/
|
||
public function injectNotifications(array $definitions): array
|
||
{
|
||
foreach ($this->extraCoreLocales() as $locale) {
|
||
$seed = $this->loadCoreSeed($locale, 'notifications');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($definitions as $defIdx => $def) {
|
||
$type = $this->resolveDefinitionType($defIdx, $def);
|
||
if (! $type || ! isset($seed[$type])) {
|
||
continue;
|
||
}
|
||
$entry = $seed[$type];
|
||
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($entry['definition'][$field])) {
|
||
$definitions[$defIdx][$field][$locale] = $entry['definition'][$field];
|
||
}
|
||
}
|
||
|
||
$templates = $def['templates'] ?? [];
|
||
foreach ($templates as $tplIdx => $tpl) {
|
||
$channel = $tpl['channel'] ?? null;
|
||
$tplSrc = $entry['templates'][$channel] ?? null;
|
||
if (! $channel || ! $tplSrc) {
|
||
continue;
|
||
}
|
||
foreach (['subject', 'body'] as $field) {
|
||
if (isset($tplSrc[$field])) {
|
||
$templates[$tplIdx][$field][$locale] = $tplSrc[$field];
|
||
}
|
||
}
|
||
}
|
||
$definitions[$defIdx]['templates'] = $templates;
|
||
}
|
||
}
|
||
|
||
return $definitions;
|
||
}
|
||
|
||
/**
|
||
* 확장(모듈/플러그인) 알림 시더용 주입기 — `seed.{target}.notifications.translations` 필터.
|
||
*
|
||
* 코어 알림과 동일한 Definition × Template 3-tier 구조를 가지지만 활성 팩 검색 범위가
|
||
* 모듈/플러그인 + 동일 target_identifier 로 한정됩니다.
|
||
*
|
||
* @param array<int, mixed> $definitions 알림 정의 배열
|
||
* @param string $targetIdentifier 대상 모듈/플러그인 식별자
|
||
* @return array<int, mixed> locale 키가 보강된 정의 배열
|
||
*/
|
||
public function injectExtensionNotifications(array $definitions, string $targetIdentifier): array
|
||
{
|
||
$packs = $this->registry->getActivePacks(LanguagePackScope::Module->value)
|
||
->merge($this->registry->getActivePacks(LanguagePackScope::Plugin->value))
|
||
->filter(fn (LanguagePack $pack) => $pack->target_identifier === $targetIdentifier);
|
||
|
||
foreach ($packs as $pack) {
|
||
$seed = $this->loadPackSeed($pack, 'notifications');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
foreach ($definitions as $defIdx => $def) {
|
||
$type = $this->resolveDefinitionType($defIdx, $def);
|
||
if (! $type || ! isset($seed[$type])) {
|
||
continue;
|
||
}
|
||
$entry = $seed[$type];
|
||
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($entry['definition'][$field])) {
|
||
$definitions[$defIdx][$field][$pack->locale] = $entry['definition'][$field];
|
||
}
|
||
}
|
||
|
||
$templates = $def['templates'] ?? [];
|
||
foreach ($templates as $tplIdx => $tpl) {
|
||
$channel = $tpl['channel'] ?? null;
|
||
$tplSrc = $entry['templates'][$channel] ?? null;
|
||
if (! $channel || ! $tplSrc) {
|
||
continue;
|
||
}
|
||
foreach (['subject', 'body'] as $field) {
|
||
if (isset($tplSrc[$field])) {
|
||
$templates[$tplIdx][$field][$pack->locale] = $tplSrc[$field];
|
||
}
|
||
}
|
||
}
|
||
$definitions[$defIdx]['templates'] = $templates;
|
||
}
|
||
}
|
||
|
||
return $definitions;
|
||
}
|
||
|
||
/**
|
||
* seed.identity_messages.translations 필터 — IDV 메시지 정의의 다국어 키 병합.
|
||
*
|
||
* 코어 IDV 메시지(config/core.php::identity_messages)의 ko/en 다국어 데이터에
|
||
* 활성 코어 언어팩의 seed/identity_messages.json 기반 추가 locale 키를 병합한다.
|
||
*
|
||
* config 의 array key (예: `mail.provider_default`, `mail.purpose.signup`)가 식별자로 사용되고,
|
||
* seed JSON 도 동일 array key 를 사용한다. 병합 대상 필드:
|
||
* - definition: name, description (배열)
|
||
* - templates[].subject, templates[].body (배열)
|
||
*
|
||
* @param array<int|string, mixed> $definitions identity_messages 정의 배열 (string-keyed 또는 numeric)
|
||
* @return array<int|string, mixed> locale 키가 보강된 정의
|
||
*/
|
||
public function injectIdentityMessages(array $definitions): array
|
||
{
|
||
foreach ($this->extraCoreLocales() as $locale) {
|
||
$seed = $this->loadCoreSeed($locale, 'identity_messages');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
|
||
foreach ($definitions as $defKey => $def) {
|
||
// config/core.php 는 string-keyed (예: 'mail.provider_default')
|
||
// Seeder 는 그 키를 사용하므로 동일 키로 매칭.
|
||
if (! isset($seed[$defKey])) {
|
||
continue;
|
||
}
|
||
$entry = $seed[$defKey];
|
||
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($entry['definition'][$field])) {
|
||
if (! isset($definitions[$defKey][$field]) || ! is_array($definitions[$defKey][$field])) {
|
||
$definitions[$defKey][$field] = [];
|
||
}
|
||
$definitions[$defKey][$field][$locale] = $entry['definition'][$field];
|
||
}
|
||
}
|
||
|
||
$templates = $def['templates'] ?? [];
|
||
foreach ($templates as $tplIdx => $tpl) {
|
||
$channel = $tpl['channel'] ?? null;
|
||
$tplSrc = $entry['templates'][$channel] ?? null;
|
||
if (! $channel || ! $tplSrc) {
|
||
continue;
|
||
}
|
||
foreach (['subject', 'body'] as $field) {
|
||
if (isset($tplSrc[$field])) {
|
||
if (! isset($templates[$tplIdx][$field]) || ! is_array($templates[$tplIdx][$field])) {
|
||
$templates[$tplIdx][$field] = [];
|
||
}
|
||
$templates[$tplIdx][$field][$locale] = $tplSrc[$field];
|
||
}
|
||
}
|
||
}
|
||
$definitions[$defKey]['templates'] = $templates;
|
||
}
|
||
}
|
||
|
||
return $definitions;
|
||
}
|
||
|
||
/**
|
||
* 확장(모듈/플러그인) IDV 메시지 시더용 주입기 — `seed.{target}.identity_messages.translations` 필터.
|
||
*
|
||
* 코어 메시지와 동일한 definition × templates × channels 3-tier 구조이지만 활성 팩 검색 범위가
|
||
* 모듈/플러그인 + 동일 target_identifier 로 한정된다. ModuleManager / PluginManager 의
|
||
* syncModuleIdentityMessages / syncPluginIdentityMessages 가 호출.
|
||
*
|
||
* @param array<int|string, mixed> $definitions IDV 메시지 정의 배열
|
||
* @param string $targetIdentifier 대상 모듈/플러그인 식별자
|
||
* @return array<int|string, mixed> locale 키가 보강된 정의
|
||
*/
|
||
public function injectExtensionIdentityMessages(array $definitions, string $targetIdentifier): array
|
||
{
|
||
$packs = $this->registry->getActivePacks(LanguagePackScope::Module->value)
|
||
->merge($this->registry->getActivePacks(LanguagePackScope::Plugin->value))
|
||
->filter(fn (LanguagePack $pack) => $pack->target_identifier === $targetIdentifier);
|
||
|
||
foreach ($packs as $pack) {
|
||
$seed = $this->loadPackSeed($pack, 'identity_messages');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
foreach ($definitions as $defKey => $def) {
|
||
// 모듈 측은 numeric-indexed 일 수 있으므로 provider_id+scope_type+scope_value 를 lookup 키로 합성.
|
||
$compositeKey = $this->identityMessageCompositeKey($def);
|
||
$matchKey = $compositeKey && isset($seed[$compositeKey])
|
||
? $compositeKey
|
||
: (is_string($defKey) && isset($seed[$defKey]) ? $defKey : null);
|
||
if (! $matchKey) {
|
||
continue;
|
||
}
|
||
$entry = $seed[$matchKey];
|
||
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($entry['definition'][$field])) {
|
||
if (! isset($definitions[$defKey][$field]) || ! is_array($definitions[$defKey][$field])) {
|
||
$definitions[$defKey][$field] = [];
|
||
}
|
||
$definitions[$defKey][$field][$pack->locale] = $entry['definition'][$field];
|
||
}
|
||
}
|
||
|
||
$templates = $def['templates'] ?? [];
|
||
foreach ($templates as $tplIdx => $tpl) {
|
||
$channel = $tpl['channel'] ?? null;
|
||
$tplSrc = $entry['templates'][$channel] ?? null;
|
||
if (! $channel || ! $tplSrc) {
|
||
continue;
|
||
}
|
||
foreach (['subject', 'body'] as $field) {
|
||
if (isset($tplSrc[$field])) {
|
||
if (! isset($templates[$tplIdx][$field]) || ! is_array($templates[$tplIdx][$field])) {
|
||
$templates[$tplIdx][$field] = [];
|
||
}
|
||
$templates[$tplIdx][$field][$pack->locale] = $tplSrc[$field];
|
||
}
|
||
}
|
||
}
|
||
$definitions[$defKey]['templates'] = $templates;
|
||
}
|
||
}
|
||
|
||
return $definitions;
|
||
}
|
||
|
||
/**
|
||
* IDV 메시지 정의의 복합 키(provider_id × scope_type × scope_value) 를 합성한다.
|
||
*
|
||
* 모듈/플러그인 정의는 numeric-indexed 이므로 키 매칭 시 합성 키를 사용한다.
|
||
* 코어 정의의 array key 패턴(`mail.provider_default`, `mail.purpose.{purpose}`)도 같은 형식.
|
||
*
|
||
* @param array<string, mixed> $def IDV 메시지 정의
|
||
* @return string|null 합성 키 또는 null (필수 필드 부재)
|
||
*/
|
||
private function identityMessageCompositeKey(array $def): ?string
|
||
{
|
||
$provider = $def['provider_id'] ?? null;
|
||
$scopeType = $def['scope_type'] ?? null;
|
||
$scopeValue = $def['scope_value'] ?? '';
|
||
if (! $provider || ! $scopeType) {
|
||
return null;
|
||
}
|
||
// provider_id 의 콜론은 키에 부적합 — channel.scope_type.scope_value 형태로 합성.
|
||
// 코어 array key 패턴 (`mail.provider_default`, `mail.purpose.signup`) 에 맞춘다.
|
||
$channel = str_contains($provider, '.mail') ? 'mail' : 'sms';
|
||
$suffix = $scopeValue ? "{$scopeType}.{$scopeValue}" : $scopeType;
|
||
|
||
return "{$channel}.{$suffix}";
|
||
}
|
||
|
||
/**
|
||
* 모듈 시더용 제네릭 주입기 — `seed.{vendor-module}.{entity}.translations` 필터.
|
||
*
|
||
* @param array<int, mixed> $entries 시드 데이터 배열
|
||
* @param string $targetIdentifier 대상 모듈/플러그인 식별자
|
||
* @param string $entity 시드 파일 이름 (확장자 제외)
|
||
* @param string $matchKey 매칭 키 컬럼명 (code/slug/key)
|
||
* @return array<int, mixed> locale 키가 보강된 시드 데이터
|
||
*/
|
||
public function injectExtensionEntity(
|
||
array $entries,
|
||
string $targetIdentifier,
|
||
string $entity,
|
||
string $matchKey = 'code'
|
||
): array {
|
||
$packs = $this->registry->getActivePacks(LanguagePackScope::Module->value)
|
||
->merge($this->registry->getActivePacks(LanguagePackScope::Plugin->value))
|
||
->filter(fn (LanguagePack $pack) => $pack->target_identifier === $targetIdentifier);
|
||
|
||
foreach ($packs as $pack) {
|
||
$seed = $this->loadPackSeed($pack, $entity);
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
foreach ($entries as $idx => $entry) {
|
||
$key = $entry[$matchKey] ?? null;
|
||
if (! $key || ! isset($seed[$key])) {
|
||
continue;
|
||
}
|
||
foreach ($seed[$key] as $field => $value) {
|
||
if (is_array($entry[$field] ?? null)) {
|
||
$entries[$idx][$field][$pack->locale] = $value;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $entries;
|
||
}
|
||
|
||
/**
|
||
* `{type}.{id}.roles.translations` 필터 — 모듈/플러그인의 roles 다국어 필드에
|
||
* 활성 언어팩의 roles seed 를 주입합니다.
|
||
*
|
||
* @param array<int, mixed> $roles module->getRoles() / plugin->getRoles() 결과
|
||
* @param string $targetIdentifier 모듈/플러그인 식별자
|
||
* @param string $scope module|plugin
|
||
* @return array<int, mixed> locale 키가 보강된 roles 배열
|
||
*/
|
||
public function injectExtensionRoles(array $roles, string $targetIdentifier, string $scope): array
|
||
{
|
||
$packs = $this->registry->getActivePacks($scope)
|
||
->filter(fn (LanguagePack $pack) => $pack->target_identifier === $targetIdentifier);
|
||
|
||
foreach ($packs as $pack) {
|
||
$seed = $this->loadPackSeed($pack, 'roles');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
foreach ($roles as $idx => $role) {
|
||
$id = $role['identifier'] ?? null;
|
||
if (! $id || ! isset($seed[$id])) {
|
||
continue;
|
||
}
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($seed[$id][$field])) {
|
||
if (! isset($roles[$idx][$field]) || ! is_array($roles[$idx][$field])) {
|
||
$roles[$idx][$field] = [];
|
||
}
|
||
$roles[$idx][$field][$pack->locale] = $seed[$id][$field];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $roles;
|
||
}
|
||
|
||
/**
|
||
* `{type}.{id}.manifest.translations` 필터 — 모듈/플러그인/템플릿의 manifest name/description
|
||
* 다국어 필드에 활성 언어팩의 manifest seed(`seed/manifest.json`)를 주입합니다.
|
||
*
|
||
* seed 형식은 `{ "name": "ja 번역", "description": "ja 번역" }` 평문이며, 각 활성 비-fallback
|
||
* locale 팩의 번역을 `$manifest['name'][$locale]`, `$manifest['description'][$locale]` 에 추가합니다.
|
||
* ko/en 등 기존 키는 보존하며, ja 팩이 없으면 입력을 그대로 반환합니다.
|
||
*
|
||
* @param array<string, mixed> $manifest `['name' => <multilingual array>, 'description' => <multilingual array>]`
|
||
* @param string $targetIdentifier 모듈/플러그인/템플릿 식별자
|
||
* @param string $scope module|plugin|template
|
||
* @return array<string, mixed> locale 키가 보강된 manifest 배열
|
||
*/
|
||
public function injectExtensionManifest(array $manifest, string $targetIdentifier, string $scope): array
|
||
{
|
||
$packs = $this->registry->getActivePacks($scope)
|
||
->filter(fn (LanguagePack $pack) => $pack->target_identifier === $targetIdentifier);
|
||
|
||
foreach ($packs as $pack) {
|
||
$seed = $this->loadPackSeed($pack, 'manifest');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
foreach (['name', 'description'] as $field) {
|
||
if (! isset($seed[$field]) || ! is_string($seed[$field]) || $seed[$field] === '') {
|
||
continue;
|
||
}
|
||
if (! isset($manifest[$field]) || ! is_array($manifest[$field])) {
|
||
$manifest[$field] = [];
|
||
}
|
||
$manifest[$field][$pack->locale] = $seed[$field];
|
||
}
|
||
}
|
||
|
||
return $manifest;
|
||
}
|
||
|
||
/**
|
||
* `{type}.{id}.permissions.translations` 필터 — 모듈/플러그인의 권한 트리에
|
||
* 활성 언어팩의 permissions seed 를 주입합니다.
|
||
*
|
||
* 권한 트리는 module/categories/permissions 의 3단계 중첩 구조이며, 각 노드는
|
||
* identifier + name/description 다국어 필드를 가집니다. seed 는 identifier ⇒ {name, description} 맵.
|
||
*
|
||
* @param array<string, mixed> $config module->getPermissions() / plugin->getPermissions() 결과
|
||
* @param string $targetIdentifier 모듈/플러그인 식별자
|
||
* @param string $scope module|plugin
|
||
* @return array<string, mixed> locale 키가 보강된 권한 config
|
||
*/
|
||
public function injectExtensionPermissions(array $config, string $targetIdentifier, string $scope): array
|
||
{
|
||
$packs = $this->registry->getActivePacks($scope)
|
||
->filter(fn (LanguagePack $pack) => $pack->target_identifier === $targetIdentifier);
|
||
|
||
foreach ($packs as $pack) {
|
||
$seed = $this->loadPackSeed($pack, 'permissions');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
$config = $this->mergePermissionTranslations($config, $seed, $pack->locale, $targetIdentifier);
|
||
}
|
||
|
||
return $config;
|
||
}
|
||
|
||
/**
|
||
* 권한 config 트리를 walk 하며 seed 의 locale 번역을 name/description 에 병합.
|
||
*
|
||
* @param array<string, mixed> $config
|
||
* @param array<string, mixed> $seed identifier ⇒ {name, description} 맵
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function mergePermissionTranslations(array $config, array $seed, string $locale, ?string $targetIdentifier = null): array
|
||
{
|
||
// module 노드 (1레벨) — config 의 root name/description 이 모듈 자체 라벨.
|
||
// seed key 는 모듈 식별자({target_identifier}) 와 동일.
|
||
$modId = $targetIdentifier;
|
||
if ($modId && isset($seed[$modId])) {
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($seed[$modId][$field])) {
|
||
if (! isset($config[$field]) || ! is_array($config[$field])) {
|
||
$config[$field] = [];
|
||
}
|
||
$config[$field][$locale] = $seed[$modId][$field];
|
||
}
|
||
}
|
||
}
|
||
|
||
// categories (2레벨) — seed key 는 `{module-id}.{cat-id}` (DB FQ identifier)
|
||
foreach (($config['categories'] ?? []) as $catIdx => $cat) {
|
||
if (empty($cat['identifier']) || ! $modId) {
|
||
continue;
|
||
}
|
||
$catFqi = "{$modId}.{$cat['identifier']}";
|
||
if (isset($seed[$catFqi])) {
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($seed[$catFqi][$field])) {
|
||
if (! isset($config['categories'][$catIdx][$field])
|
||
|| ! is_array($config['categories'][$catIdx][$field])) {
|
||
$config['categories'][$catIdx][$field] = [];
|
||
}
|
||
$config['categories'][$catIdx][$field][$locale] = $seed[$catFqi][$field];
|
||
}
|
||
}
|
||
}
|
||
// permissions (3레벨) — seed key 는 `{module-id}.{cat-id}.{perm-id}`
|
||
// 권한 노드의 식별자는 `action`(이커머스 등) 또는 `identifier`(명시적). 둘 중 존재 값 사용.
|
||
foreach (($cat['permissions'] ?? []) as $pIdx => $perm) {
|
||
$permId = $perm['action'] ?? $perm['identifier'] ?? null;
|
||
if (! $permId) {
|
||
continue;
|
||
}
|
||
$permFqi = "{$catFqi}.{$permId}";
|
||
if (isset($seed[$permFqi])) {
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($seed[$permFqi][$field])) {
|
||
if (! isset($config['categories'][$catIdx]['permissions'][$pIdx][$field])
|
||
|| ! is_array($config['categories'][$catIdx]['permissions'][$pIdx][$field])) {
|
||
$config['categories'][$catIdx]['permissions'][$pIdx][$field] = [];
|
||
}
|
||
$config['categories'][$catIdx]['permissions'][$pIdx][$field][$locale] = $seed[$permFqi][$field];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return $config;
|
||
}
|
||
|
||
/**
|
||
* `module.{id}.admin_menus.translations` 필터 — 모듈의 admin_menus 다국어 필드에
|
||
* 활성 모듈 언어팩의 menus seed 를 주입합니다.
|
||
*
|
||
* 메뉴는 children 으로 중첩되므로 재귀적으로 walk 합니다. 매칭은 `slug` 키 기준이며
|
||
* seed 형식은 `{ slug: { name: 'ja translation' } }` 입니다.
|
||
*
|
||
* @param array<int, mixed> $menus module->getAdminMenus() 결과
|
||
* @param string $targetIdentifier 모듈 식별자
|
||
* @return array<int, mixed> locale 키가 보강된 메뉴 배열
|
||
*/
|
||
public function injectExtensionMenus(array $menus, string $targetIdentifier): array
|
||
{
|
||
$packs = $this->registry->getActivePacks(LanguagePackScope::Module->value)
|
||
->filter(fn (LanguagePack $pack) => $pack->target_identifier === $targetIdentifier);
|
||
|
||
foreach ($packs as $pack) {
|
||
$seed = $this->loadPackSeed($pack, 'menus');
|
||
if (! $seed) {
|
||
continue;
|
||
}
|
||
$menus = $this->mergeMenuTranslations($menus, $seed, $pack->locale);
|
||
}
|
||
|
||
return $menus;
|
||
}
|
||
|
||
/**
|
||
* 메뉴 배열을 재귀적으로 walk 하며 seed 의 locale 번역을 name 필드에 병합합니다.
|
||
*
|
||
* @param array<int, mixed> $menus
|
||
* @param array<string, mixed> $seed slug ⇒ {name: ...} 맵
|
||
* @return array<int, mixed>
|
||
*/
|
||
private function mergeMenuTranslations(array $menus, array $seed, string $locale): array
|
||
{
|
||
foreach ($menus as $idx => $menu) {
|
||
$slug = $menu['slug'] ?? null;
|
||
if ($slug && isset($seed[$slug]['name'])) {
|
||
if (! isset($menus[$idx]['name']) || ! is_array($menus[$idx]['name'])) {
|
||
$menus[$idx]['name'] = [];
|
||
}
|
||
$menus[$idx]['name'][$locale] = $seed[$slug]['name'];
|
||
}
|
||
|
||
if (! empty($menu['children']) && is_array($menu['children'])) {
|
||
$menus[$idx]['children'] = $this->mergeMenuTranslations($menu['children'], $seed, $locale);
|
||
}
|
||
}
|
||
|
||
return $menus;
|
||
}
|
||
|
||
/**
|
||
* 활성 코어 언어팩 중 fallback locale 외의 locale 목록.
|
||
*
|
||
* fallback locale 의 다국어 키는 이미 config/seed 에 포함되어 있으므로 추가 주입 대상이 아닙니다.
|
||
* primary locale (현재 사용자 언어)은 다른 사용자가 볼 수도 있으므로 항상 주입 대상에 포함합니다.
|
||
*
|
||
* @return array<int, string> locale 문자열 배열
|
||
*/
|
||
private function extraCoreLocales(): array
|
||
{
|
||
$fallback = config('app.fallback_locale', 'ko');
|
||
|
||
return collect($this->registry->getActiveCoreLocales())
|
||
->reject(fn ($locale) => $locale === $fallback)
|
||
->values()
|
||
->all();
|
||
}
|
||
|
||
/**
|
||
* 코어 언어팩 1건의 seed/{entity}.json 을 로드합니다.
|
||
*
|
||
* @param string $locale 로케일
|
||
* @param string $entity 엔티티 이름 (permissions, roles, menus 등)
|
||
* @return array<string, mixed>|null seed 데이터 또는 null
|
||
*/
|
||
private function loadCoreSeed(string $locale, string $entity): ?array
|
||
{
|
||
$pack = $this->registry->getActivePackForSlot(LanguagePackScope::Core->value, null, $locale);
|
||
if (! $pack) {
|
||
return null;
|
||
}
|
||
|
||
return $this->loadPackSeed($pack, $entity);
|
||
}
|
||
|
||
/**
|
||
* 언어팩 디렉토리의 seed/{entity}.json 을 읽어 배열로 반환합니다.
|
||
*
|
||
* @param LanguagePack $pack 대상 언어팩
|
||
* @param string $entity 엔티티 이름
|
||
* @return array<string, mixed>|null 파일 내용 또는 null
|
||
*/
|
||
private function loadPackSeed(LanguagePack $pack, string $entity): ?array
|
||
{
|
||
$seedFile = $pack->resolveDirectory().DIRECTORY_SEPARATOR.'seed'.DIRECTORY_SEPARATOR.$entity.'.json';
|
||
|
||
if (! File::isFile($seedFile)) {
|
||
return null;
|
||
}
|
||
|
||
$decoded = json_decode(File::get($seedFile), true);
|
||
if (! is_array($decoded)) {
|
||
Log::warning('language-pack seed JSON invalid', [
|
||
'pack' => $pack->identifier,
|
||
'file' => $seedFile,
|
||
]);
|
||
|
||
return null;
|
||
}
|
||
|
||
return $decoded;
|
||
}
|
||
|
||
/**
|
||
* config('core.permissions') 의 계층형 구조에 locale 키를 병합합니다.
|
||
*
|
||
* @param array<string, mixed> $config permission 설정
|
||
* @param array<string, mixed> $seed seed JSON 데이터 (key ⇒ {name, description})
|
||
* @param string $locale 로케일
|
||
* @return array<string, mixed> 병합된 설정
|
||
*/
|
||
private function mergeIntoPermissionConfig(array $config, array $seed, string $locale): array
|
||
{
|
||
if (isset($config['module']) && isset($seed[$config['module']['identifier'] ?? '_'])) {
|
||
$entry = $seed[$config['module']['identifier']];
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($entry[$field]) && is_array($config['module'][$field] ?? null)) {
|
||
$config['module'][$field][$locale] = $entry[$field];
|
||
}
|
||
}
|
||
}
|
||
|
||
$categories = $config['categories'] ?? [];
|
||
foreach ($categories as $cIdx => $category) {
|
||
$catId = $category['identifier'] ?? null;
|
||
if ($catId && isset($seed[$catId])) {
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($seed[$catId][$field]) && is_array($category[$field] ?? null)) {
|
||
$categories[$cIdx][$field][$locale] = $seed[$catId][$field];
|
||
}
|
||
}
|
||
}
|
||
|
||
$permissions = $category['permissions'] ?? [];
|
||
foreach ($permissions as $pIdx => $perm) {
|
||
$permId = $perm['identifier'] ?? null;
|
||
if (! $permId || ! isset($seed[$permId])) {
|
||
continue;
|
||
}
|
||
foreach (['name', 'description'] as $field) {
|
||
if (isset($seed[$permId][$field]) && is_array($perm[$field] ?? null)) {
|
||
$permissions[$pIdx][$field][$locale] = $seed[$permId][$field];
|
||
}
|
||
}
|
||
}
|
||
$categories[$cIdx]['permissions'] = $permissions;
|
||
}
|
||
$config['categories'] = $categories;
|
||
|
||
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;
|
||
}
|
||
}
|