Files
Gnuboard7/app/Providers/LanguagePackServiceProvider.php
HeuJung b10e89bef0 fix(core,board,ecommerce,page,gdpr,ckeditor5,kginicis,admin_basic,basic): 목록 조회·검증 계층 정비 및 감사 지적 전건 처리
목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친
감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다.

검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다.
옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던
공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글
목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가
되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는
접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다.

ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로
로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에
사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다.

하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서
통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가
요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다.

정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다.
탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
2026-08-02 15:29:29 +09:00

548 lines
24 KiB
PHP

<?php
namespace App\Providers;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Contracts\Repositories\LanguagePackTranslationRepositoryInterface;
use App\Enums\LanguagePackScope;
use App\Extension\HookManager;
use App\Listeners\LanguagePack\MergeCustomTranslations;
use App\Listeners\LanguagePack\MergeFrontendLanguage;
use App\Listeners\LanguagePack\RunSeedersOnLanguagePackLifecycle;
use App\Listeners\LanguagePack\SyncDatabaseTranslations;
use App\Models\LanguagePack;
use App\Repositories\LanguagePackRepository;
use App\Repositories\LanguagePackTranslationRepository;
use App\Services\LanguagePack\LanguagePackBundledRegistrar;
use App\Services\LanguagePack\LanguagePackRegistry;
use App\Services\LanguagePack\LanguagePackSeedInjector;
use App\Services\LanguagePack\LanguagePackTranslator;
use App\Support\InstallerContext;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\ServiceProvider;
use Illuminate\Translation\TranslationServiceProvider;
use Throwable;
/**
* 언어팩 시스템의 부팅을 담당하는 서비스 프로바이더.
*
* 1. Repository 인터페이스 ⇒ 구현체 바인딩
* 2. LanguagePackRegistry 싱글톤 등록
* 3. Translator 를 LanguagePackTranslator 로 교체 (코어 폴백 지원)
* 4. 부팅 후 활성 언어팩의 backend 디렉토리를 Translator 에 등록
* 5. config('app.supported_locales') / locale_names 동적 갱신
*/
class LanguagePackServiceProvider extends ServiceProvider
{
/**
* 컨테이너 바인딩을 등록합니다.
*/
public function register(): void
{
$this->app->bind(
LanguagePackRepositoryInterface::class,
LanguagePackRepository::class
);
$this->app->bind(
LanguagePackTranslationRepositoryInterface::class,
LanguagePackTranslationRepository::class
);
$this->app->singleton(LanguagePackRegistry::class, function (Application $app) {
return new LanguagePackRegistry(
$app->make(LanguagePackRepositoryInterface::class)
);
});
$this->app->singleton(LanguagePackSeedInjector::class, function (Application $app) {
return new LanguagePackSeedInjector(
$app->make(LanguagePackRegistry::class)
);
});
$this->app->singleton(LanguagePackBundledRegistrar::class, function (Application $app) {
return new LanguagePackBundledRegistrar(
$app->make(LanguagePackRepositoryInterface::class),
$app->make(LanguagePackRegistry::class),
$app->make(CacheInterface::class),
);
});
$this->registerTranslatorOverride();
}
/**
* 부팅 시 활성 언어팩의 번역 경로를 Translator 에 등록합니다.
*
* DB 가 준비되지 않았거나(설치 전) language_packs 테이블이 없으면 조용히 건너뜁니다.
*/
public function boot(): void
{
$this->app->booted(function () {
if (! $this->isRegistryReady()) {
return;
}
try {
/** @var LanguagePackRegistry $registry */
$registry = $this->app->make(LanguagePackRegistry::class);
foreach ($registry->getActivePacks() as $pack) {
$this->registerActivePack($pack);
}
$this->refreshSupportedLocales($registry);
$this->registerSeedFilters();
$this->registerEventListeners();
} catch (Throwable $e) {
report($e);
}
});
}
/**
* HookManager 필터에 LanguagePackSeedInjector 의 메서드를 등록합니다.
*
* 시더가 applyFilters() 를 호출하면 활성 코어 언어팩의 seed/*.json 으로 다국어 키가 보강됩니다.
*/
private function registerSeedFilters(): void
{
$injector = $this->app->make(LanguagePackSeedInjector::class);
HookManager::addFilter('core.permissions.config', function ($config) use ($injector) {
return $injector->injectCorePermissions(is_array($config) ? $config : []);
});
HookManager::addFilter('core.roles.config', function ($roles) use ($injector) {
return $injector->injectCoreRoles(is_array($roles) ? $roles : []);
});
HookManager::addFilter('core.menus.config', function ($menus) use ($injector) {
return $injector->injectCoreMenus(is_array($menus) ? $menus : []);
});
HookManager::addFilter('seed.notifications.translations', function ($definitions) use ($injector) {
return $injector->injectNotifications(is_array($definitions) ? $definitions : []);
});
HookManager::addFilter('seed.identity_messages.translations', function ($definitions) use ($injector) {
return $injector->injectIdentityMessages(is_array($definitions) ? $definitions : []);
});
$this->registerExtensionSeedFilters($injector);
// 프론트엔드 다국어 데이터 병합 (TemplateService::getLanguageDataWithModules 의 마지막 단계)
$merger = $this->app->make(MergeFrontendLanguage::class);
HookManager::addFilter('template.language.merge', function ($data, $templateIdentifier = '', $locale = 'ko') use ($merger) {
return $merger(is_array($data) ? $data : [], (string) $templateIdentifier, (string) $locale);
}, 10);
// 커스텀 다국어 키 병합 — 언어팩 병합 다음(priority 20)에 실행되어
// template_custom_translations 의 활성 키가 언어팩 위에 덮어쓰도록 우선순위를 높게 둔다.
$customMerger = $this->app->make(MergeCustomTranslations::class);
HookManager::addFilter('template.language.merge', function ($data, $templateIdentifier = '', $locale = 'ko') use ($customMerger) {
return $customMerger(is_array($data) ? $data : [], (string) $templateIdentifier, (string) $locale);
}, 20);
}
/**
* 활성 모듈/플러그인 언어팩의 seed/*.json 마다 ext entity 시드 필터(`seed.{target}.{entity}.translations`)에
* LanguagePackSeedInjector::injectExtensionEntity() 를 자동 결선합니다.
*
* 시더가 매칭 키(code/slug/key/identifier) 중 하나로 entry 를 식별하므로, entries[0] 에서 매칭 키 후보를
* 우선순위로 자동 감지합니다. 후보 미발견 시 'code' 를 기본값으로 사용합니다.
*
* @param LanguagePackSeedInjector $injector 주입기
*/
public function registerExtensionSeedFilters(LanguagePackSeedInjector $injector): void
{
$registry = $this->app->make(LanguagePackRegistry::class);
$candidates = collect()
->merge($registry->getActivePacks(LanguagePackScope::Module->value))
->merge($registry->getActivePacks(LanguagePackScope::Plugin->value))
->merge($registry->getActivePacks(LanguagePackScope::Template->value));
foreach ($candidates as $pack) {
$seedDir = $pack->resolveDirectory().DIRECTORY_SEPARATOR.'seed';
if (! is_dir($seedDir)) {
continue;
}
$target = (string) $pack->target_identifier;
if ($target === '') {
continue;
}
foreach (glob($seedDir.DIRECTORY_SEPARATOR.'*.json') as $seedFile) {
$entity = pathinfo($seedFile, PATHINFO_FILENAME);
if ($entity === 'notifications') {
HookManager::addFilter("seed.{$target}.notifications.translations", function ($definitions) use ($injector, $target) {
return $injector->injectExtensionNotifications(is_array($definitions) ? $definitions : [], $target);
});
continue;
}
if ($entity === 'identity_messages') {
HookManager::addFilter("seed.{$target}.identity_messages.translations", function ($definitions) use ($injector, $target) {
return $injector->injectExtensionIdentityMessages(is_array($definitions) ? $definitions : [], $target);
});
continue;
}
if ($entity === 'menus') {
// 모듈 admin_menus 동기화 시 ModuleManager 가 발행하는 필터에 결선 (모듈 전용).
HookManager::addFilter("module.{$target}.admin_menus.translations", function ($menus) use ($injector, $target) {
return $injector->injectExtensionMenus(is_array($menus) ? $menus : [], $target);
});
continue;
}
if ($entity === 'roles') {
// 모듈/플러그인 roles 동기화 시 발행되는 필터에 결선.
$scopeStr = $pack->scope;
HookManager::addFilter("{$scopeStr}.{$target}.roles.translations", function ($roles) use ($injector, $target, $scopeStr) {
return $injector->injectExtensionRoles(is_array($roles) ? $roles : [], $target, $scopeStr);
});
continue;
}
if ($entity === 'permissions') {
// 모듈/플러그인 permissions 트리 동기화 시 발행되는 필터에 결선.
$scopeStr = $pack->scope;
HookManager::addFilter("{$scopeStr}.{$target}.permissions.translations", function ($config) use ($injector, $target, $scopeStr) {
return $injector->injectExtensionPermissions(is_array($config) ? $config : [], $target, $scopeStr);
});
continue;
}
if ($entity === 'manifest') {
// 모듈/플러그인/템플릿 manifest(name/description) 동기화 시 Manager 가 발행하는 필터에 결선.
$scopeStr = $pack->scope;
HookManager::addFilter("{$scopeStr}.{$target}.manifest.translations", function ($manifest) use ($injector, $target, $scopeStr) {
return $injector->injectExtensionManifest(is_array($manifest) ? $manifest : [], $target, $scopeStr);
});
continue;
}
HookManager::addFilter("seed.{$target}.{$entity}.translations", function ($entries) use ($injector, $target, $entity) {
if (! is_array($entries) || empty($entries)) {
return $entries;
}
$matchKey = $this->detectMatchKey($entries[0] ?? []);
return $injector->injectExtensionEntity($entries, $target, $entity, $matchKey);
});
}
}
}
/**
* 시드 entry 에서 매칭 키 컬럼을 자동 감지합니다.
*
* 우선순위: code > slug > key > identifier > id. 미발견 시 'code'.
*
* @param array<string, mixed> $entry 시드 entry
* @return string 매칭 키 컬럼명
*/
private function detectMatchKey(array $entry): string
{
foreach (['code', 'slug', 'key', 'identifier', 'id'] as $key) {
if (array_key_exists($key, $entry)) {
return $key;
}
}
return 'code';
}
/**
* `core.language_packs.activated` / `core.language_packs.deactivated` 액션 훅에
* SyncDatabaseTranslations 리스너를 연결합니다.
*
* G7 표준 훅 메커니즘(HookManager::doAction → addAction) 으로 일원화 — 별도 Event 클래스 사용 안 함.
* 훅 명명은 모듈/플러그인/템플릿 (`core.{type}.activated/deactivated/installed/updated/uninstalled`) 와 동일.
*/
private function registerEventListeners(): void
{
$listener = $this->app->make(SyncDatabaseTranslations::class);
HookManager::addAction('core.language_packs.activated', function ($pack) use ($listener) {
$listener->handleActivated($pack);
});
HookManager::addAction('core.language_packs.deactivated', function ($pack) use ($listener) {
$listener->handleDeactivated($pack);
});
// entity 시더 자동 재실행 (board_types/claim_reasons 등 다국어 JSON 데이터의 활성 locale 동기화)
$reseeder = $this->app->make(RunSeedersOnLanguagePackLifecycle::class);
HookManager::addAction('core.language_packs.activated', function ($pack) use ($reseeder) {
$reseeder->handleActivated($pack);
});
HookManager::addAction('core.language_packs.deactivated', function ($pack) use ($reseeder) {
$reseeder->handleDeactivated($pack);
});
$this->registerBundledRegistrarHooks();
}
/**
* 확장(모듈/플러그인/템플릿) 설치/제거/업데이트 후크에 가상 등록 리스너를 연결합니다.
*
* 모듈 설치 후 모듈의 lang 디렉토리를 스캔하여 `bundled_with_extension` 가상 레코드를
* `language_packs` 테이블에 자동 등록합니다 (계획서).
*/
private function registerBundledRegistrarHooks(): void
{
$registrar = $this->app->make(LanguagePackBundledRegistrar::class);
$syncFor = function (string $scope, string $identifier, ?array $info, string $relativeBase) use ($registrar) {
$vendor = $this->resolveVendor($identifier, $info);
$version = (string) ($info['version'] ?? '1.0.0');
$langDir = $this->resolveExtensionLangDir($scope, $identifier, $relativeBase);
$registrar->syncFromExtension($scope, $identifier, $vendor, $version, $langDir);
};
// 모듈
HookManager::addAction('core.modules.after_install', function ($identifier, $info = null) use ($syncFor) {
$syncFor('module', (string) $identifier, is_array($info) ? $info : null, 'modules');
});
HookManager::addAction('core.modules.after_update', function ($identifier, $result = null, $info = null) use ($syncFor) {
$syncFor('module', (string) $identifier, is_array($info) ? $info : null, 'modules');
});
HookManager::addAction('core.modules.after_uninstall', function ($identifier) use ($registrar) {
$registrar->cleanupForExtension('module', (string) $identifier);
});
// 플러그인
HookManager::addAction('core.plugins.after_install', function ($identifier, $info = null) use ($syncFor) {
$syncFor('plugin', (string) $identifier, is_array($info) ? $info : null, 'plugins');
});
HookManager::addAction('core.plugins.after_update', function ($identifier, $result = null, $info = null) use ($syncFor) {
$syncFor('plugin', (string) $identifier, is_array($info) ? $info : null, 'plugins');
});
HookManager::addAction('core.plugins.after_uninstall', function ($identifier) use ($registrar) {
$registrar->cleanupForExtension('plugin', (string) $identifier);
});
// 템플릿
HookManager::addAction('core.templates.after_install', function ($identifier, $info = null) use ($syncFor) {
$syncFor('template', (string) $identifier, is_array($info) ? $info : null, 'templates');
});
HookManager::addAction('core.templates.after_update', function ($templateOrId, $data = null) use ($syncFor) {
$identifier = is_object($templateOrId) ? ($templateOrId->identifier ?? '') : (string) $templateOrId;
if ($identifier !== '') {
$syncFor('template', $identifier, is_array($data) ? $data : null, 'templates');
}
});
HookManager::addAction('core.templates.after_uninstall', function ($identifier) use ($registrar) {
$registrar->cleanupForExtension('template', (string) $identifier);
});
// 요구사항 #6: 호스트 확장 비활성화 시 종속 언어팩 cascade 비활성화
HookManager::addAction('core.modules.after_deactivate', function ($identifier) use ($registrar) {
$registrar->deactivateForExtension('module', (string) $identifier);
});
HookManager::addAction('core.plugins.after_deactivate', function ($identifier) use ($registrar) {
$registrar->deactivateForExtension('plugin', (string) $identifier);
});
HookManager::addAction('core.templates.after_deactivate', function ($identifier) use ($registrar) {
$registrar->deactivateForExtension('template', (string) $identifier);
});
}
/**
* 확장의 백엔드 다국어 디렉토리를 반환합니다.
*
* **런타임이 실제로 로드하는 경로와 같아야 한다** (`TranslationServiceProvider`).
* 모듈은 `src/lang`, 플러그인은 `lang` 이며, 코어의 확장 설치 검증
* (`ValidatesTranslationPath`)도 같은 규칙을 강제한다. 여기서 다른 경로를 잡으면
* 언어팩에 등록된 키와 화면에 나오는 문구의 출처가 어긋난다.
*
* 존재하지 않으면 관례 경로들을 순서대로 확인해 실제 있는 디렉토리를 돌려준다
* (제3자 확장이 다른 배치를 쓰는 경우 대비).
*
* @param string $scope 확장 스코프 (module|plugin|template)
* @param string $identifier 확장 식별자
* @param string $relativeBase base_path 기준 상위 디렉토리 (modules|plugins|templates)
* @return string base_path 기준 상대 lang 디렉토리
*/
private function resolveExtensionLangDir(string $scope, string $identifier, string $relativeBase): string
{
$primary = $scope === 'module' ? 'src/lang' : 'lang';
$candidates = array_unique([$primary, 'lang', 'src/lang', 'resources/lang']);
foreach ($candidates as $candidate) {
$dir = $relativeBase.'/'.$identifier.'/'.$candidate;
if (File::isDirectory(base_path($dir))) {
return $dir;
}
}
return $relativeBase.'/'.$identifier.'/'.$primary;
}
/**
* 확장 manifest 또는 identifier 로부터 vendor 를 추출합니다.
*
* @param string $identifier 확장 식별자
* @param array<string, mixed>|null $info manifest 정보
* @return string vendor 문자열
*/
private function resolveVendor(string $identifier, ?array $info): string
{
if (is_array($info) && ! empty($info['vendor'])) {
return (string) $info['vendor'];
}
$segments = explode('-', $identifier);
return $segments[0] ?? 'unknown';
}
/**
* Laravel 의 Translator 바인딩을 LanguagePackTranslator 로 교체합니다.
*
* Illuminate\Translation\TranslationServiceProvider 가 'translator' 와
* 'translation.loader' 를 등록하는 시점 이후에 본 메서드가 호출되어야 하므로,
* 부트스트랩 순서상 본 ServiceProvider 는 TranslationServiceProvider 다음에 등록됩니다.
*/
private function registerTranslatorOverride(): void
{
$this->app->extend('translator', function ($translator, Application $app) {
$loader = $app->make('translation.loader');
$locale = $app->getLocale();
$decorated = new LanguagePackTranslator($loader, $locale);
$decorated->setFallback($app['config']->get('app.fallback_locale'));
return $decorated;
});
}
/**
* 활성 언어팩 1건을 Translator 에 등록합니다.
*
* @param LanguagePack $pack 활성 언어팩
*/
private function registerActivePack(LanguagePack $pack): void
{
$directory = $pack->resolveDirectory().DIRECTORY_SEPARATOR.'backend';
if (! File::isDirectory($directory)) {
return;
}
if ($pack->scope === LanguagePackScope::Core->value) {
$translator = $this->app->make('translator');
if ($translator instanceof LanguagePackTranslator) {
$localeDir = $directory.DIRECTORY_SEPARATOR.$pack->locale;
if (File::isDirectory($localeDir)) {
$translator->addCoreFallbackPath($pack->locale, $localeDir);
} else {
// backend 직속에 PHP 배열을 두는 평탄형 구조도 지원
$translator->addCoreFallbackPath($pack->locale, $directory);
}
}
return;
}
// module/plugin/template 은 네임스페이스 기반 등록.
//
// Laravel FileLoader::addNamespace 는 단일 hint 만 보유하며 덮어쓰는 구조이므로,
// loadTranslationsFrom() 으로 직접 등록하면 모듈 자체 src/lang 의 ko/en 등록을
// 덮어써 ko 가 raw key 로 떨어지는 회귀가 발생한다. 따라서 LanguagePackTranslator
// 의 namespace fallback 메커니즘에 등록해 표준 hint 를 유지한 채 ja 등 추가
// locale 만 보완한다.
if (! empty($pack->target_identifier)) {
$translator = $this->app->make('translator');
if ($translator instanceof LanguagePackTranslator) {
$localeDir = $directory.DIRECTORY_SEPARATOR.$pack->locale;
$fallbackDir = File::isDirectory($localeDir) ? $localeDir : $directory;
$translator->addNamespaceFallbackPath(
namespace: (string) $pack->target_identifier,
locale: $pack->locale,
path: $fallbackDir,
);
}
}
}
/**
* config('app.supported_locales') / locale_names / translatable_locales 를 갱신합니다.
*
* @param LanguagePackRegistry $registry 레지스트리
*/
private function refreshSupportedLocales(LanguagePackRegistry $registry): void
{
$activeLocales = $registry->getActiveCoreLocales();
// translatable_locales 도 함께 갱신 — 활성 코어 언어팩의 locale 이 데이터 입력
// 화이트리스트(LocaleRequiredTranslatable / TranslatableField Rule)에 포함되지 않으면
// 모듈/플러그인의 다국어 폼이 ja 등을 거부하는 회귀가 발생한다.
config([
'app.supported_locales' => $activeLocales,
'app.locale_names' => $registry->getLocaleNames(),
'app.translatable_locales' => $activeLocales,
]);
}
/**
* Registry 사용 가능 여부를 확인합니다.
*
* 설치 완료 플래그(config('app.installer_completed'))가 true 인 환경에서는 hasTable 호출을 생략하여
* 부팅 비용을 줄입니다. 마이그레이션 명령 실행 중이거나 .env 가 부재하면 안전하게 스킵합니다.
*
* @return bool 준비 여부
*/
private function isRegistryReady(): bool
{
if (! file_exists(base_path('.env'))) {
return false;
}
if (InstallerContext::isSchemaMutatingCommand()) {
return false;
}
if (config('app.installer_completed') === true) {
return true;
}
try {
return Schema::hasTable('language_packs');
} catch (QueryException $e) {
return false;
} catch (Throwable $e) {
return false;
}
}
/**
* 의존하는 다른 ServiceProvider 가 먼저 등록되어야 함을 명시합니다.
*
* @return array<int, string>
*/
public function provides(): array
{
return [
LanguagePackRepositoryInterface::class,
LanguagePackRegistry::class,
];
}
/**
* Laravel 기본 TranslationServiceProvider 클래스 참조 (참고용).
*/
public static function dependsOn(): string
{
return TranslationServiceProvider::class;
}
}