From e819db9d42f56512a1008e46da04175f348d8dc6 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Mon, 17 Aug 2026 13:22:48 +0900 Subject: [PATCH] =?UTF-8?q?fix(core):=20=EA=B3=B5=EA=B0=9C=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20config=20=EC=BA=90=EC=8B=9C=20=EB=AC=B4?= =?UTF-8?q?=ED=9A=A8=ED=99=94=20=EB=88=84=EB=9D=BD=EA=B3=BC=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=EC=BA=90=EC=8B=9C=20=EC=82=AD=EC=A0=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=99=EC=A2=85=20=EA=B2=B0=ED=95=A8=20=EC=A0=95?= =?UTF-8?q?=EB=B9=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공개 config.json 의 고정 캐시 키(template.config.{identifier})가 어떤 갱신 경로에서도 forget 되지 않아 template:update/cache-clear/비활성화/삭제 후에도 최대 1시간(TTL) 이전 manifest 로 응답되던 결함(공개 )을 수정한다. - TemplateManager::clearTemplateCache 공개화 — config/components_manifest(두 변종)/현재 버전 routes·language 키 능동 forget, updateTemplate cleanup 호출 - forgetLayoutCacheKeys 가 편집기용 .with_source_meta 변종까지 삭제 - ext.cache_version 쓰기를 incrementExtensionCacheVersion 단일 지점으로 통일 - module/plugin:cache-clear 의 유령 키 forget 을 실재 캐시(레이아웃 서빙/해석 캐시, 상태 키, 미들웨어 인덱스, 버전 bump) 무효화로 교체 - 공개 routes/language/layout API 의 ?v 생략 폴백을 0 → 현재 버전으로 교정 --- CHANGELOG.md | 3 + .../Module/ClearModuleCacheCommand.php | 78 +++---- .../Plugin/ClearPluginCacheCommand.php | 70 +++---- .../Template/ClearTemplateCacheCommand.php | 75 ++----- app/Extension/TemplateManager.php | 80 ++++++-- .../Traits/InvalidatesLayoutCache.php | 31 ++- .../Api/Public/PublicLayoutController.php | 11 +- .../Api/Public/PublicTemplateController.php | 17 +- .../MarkOrphanedCustomTranslations.php | 11 +- app/Services/LayoutExtensionService.php | 10 +- app/Services/LayoutService.php | 11 +- .../TemplateCustomTranslationService.php | 13 +- docs/backend/api/templates.md | 2 +- docs/cheatsheet.md | 2 +- docs/extension/template-caching.md | 91 +++++---- docs/extension/template-commands.md | 2 +- .../Feature/Api/Public/LayoutServingTest.php | 11 +- .../Module/ModuleArtisanCommandsTest.php | 80 ++++++++ .../Plugin/PluginArtisanCommandsTest.php | 54 +++++ .../Template/TemplateArtisanCommandsTest.php | 26 ++- .../Template/PublicLayoutControllerTest.php | 15 +- .../Template/PublicTemplateControllerTest.php | 104 +++++++++- .../Template/TemplateCacheManagementTest.php | 190 ++++++++++++++++++ .../Template/TemplateLanguageServingTest.php | 12 +- .../Cache/Migration/ArtisanCacheClearTest.php | 20 +- ...plateUpdateConfigCacheInvalidationTest.php | 86 ++++++++ ...late-public-config-cache-invalidation.yaml | 33 +++ 27 files changed, 891 insertions(+), 247 deletions(-) create mode 100644 tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php create mode 100644 tests/scenarios/template-public-config-cache-invalidation.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index ab93fc0c..fa086604 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,9 @@ - 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다. - Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다. - 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다. +- 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.) +- 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다. +- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다. ## [7.0.6] - 2026-08-10 diff --git a/app/Console/Commands/Module/ClearModuleCacheCommand.php b/app/Console/Commands/Module/ClearModuleCacheCommand.php index 6c2d5362..5acd7b49 100644 --- a/app/Console/Commands/Module/ClearModuleCacheCommand.php +++ b/app/Console/Commands/Module/ClearModuleCacheCommand.php @@ -2,15 +2,22 @@ namespace App\Console\Commands\Module; -use App\Contracts\Extension\CacheInterface; -use App\Contracts\Repositories\ModuleRepositoryInterface; +use App\Contracts\Repositories\LayoutRepositoryInterface; +use App\Enums\LayoutSourceType; +use App\Extension\ExtensionMiddlewareRegistry; use App\Extension\ModuleManager; +use App\Extension\Traits\ClearsTemplateCaches; +use App\Extension\Traits\InvalidatesLayoutCache; use App\Services\ExtensionBundleService; +use App\Services\LayoutResolverService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; class ClearModuleCacheCommand extends Command { + use ClearsTemplateCaches; + use InvalidatesLayoutCache; + /** * The name and signature of the console command. */ @@ -27,15 +34,19 @@ class ClearModuleCacheCommand extends Command */ public function __construct( private ModuleManager $moduleManager, - private ModuleRepositoryInterface $moduleRepository, - private CacheInterface $cache, - private ExtensionBundleService $bundleService + protected LayoutRepositoryInterface $layoutRepository, + private ExtensionBundleService $bundleService, + private LayoutResolverService $layoutResolver ) { parent::__construct(); } /** * Execute the console command. + * + * 실재하는 서버 캐시(모듈 레이아웃 서빙/해석 캐시 + 상태 키 + 미들웨어 인덱스 + * + 버전 포함 키)를 무효화한다. + * 종전 forget 대상(module.config.{id} 등)은 writer 가 없는 유령 키였다 (#588). */ public function handle(): int { @@ -61,6 +72,11 @@ class ClearModuleCacheCommand extends Command $clearedCount = $this->clearModuleCache($identifier); + // 상태 키(ext.modules.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump) + ModuleManager::invalidateModuleStatusCache(); + ExtensionMiddlewareRegistry::flush(); + $this->incrementExtensionCacheVersion(); + $this->info('✅ '.__('modules.commands.cache_clear.success_single', [ 'module' => $identifier, 'count' => $clearedCount, @@ -69,25 +85,16 @@ class ClearModuleCacheCommand extends Command // 모든 모듈 캐시 삭제 $this->info(__('modules.commands.cache_clear.clearing_all')); - // 전체 모듈 캐시 키 삭제 - $cacheKeys = [ - 'modules.all', - 'modules.active', - 'modules.installed', - ]; - - foreach ($cacheKeys as $key) { - if ($this->cache->forget($key)) { - $clearedCount++; - } - } - // 각 모듈별 캐시 삭제 - $allModules = $this->moduleManager->getAllModules(); - foreach ($allModules as $moduleName => $module) { + foreach ($this->moduleManager->getAllModules() as $module) { $clearedCount += $this->clearModuleCache($module->getIdentifier()); } + // 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분 + ModuleManager::invalidateModuleStatusCache(); + ExtensionMiddlewareRegistry::flush(); + $this->incrementExtensionCacheVersion(); + // 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제) $clearedCount += $this->bundleService->clearBundles('module'); @@ -113,26 +120,23 @@ class ClearModuleCacheCommand extends Command /** * 특정 모듈의 캐시를 삭제합니다. + * + * 모듈이 소유한 실재 서버 캐시는 레이아웃 캐시(서빙/병합 + 해석)다 — 각각 + * 트레이트(InvalidatesLayoutCache)와 LayoutResolverService 단일 지점으로 + * 위임해 키 규약 드리프트를 방지한다. 해석 캐시(layout_resolver.*)는 버전 + * 접미사 없는 고정 키에 레이아웃 row ID 를 저장하므로 능동 삭제하지 않으면 + * TTL 동안 이전 해석 결과가 유지된다 (#588 동종 보강). + * + * @param string $identifier 모듈 식별자 + * @return int 캐시를 무효화한 레이아웃 수 */ private function clearModuleCache(string $identifier): int { - $clearedCount = 0; + $this->invalidateExtensionLayoutCache($identifier, 'module'); + $this->layoutResolver->clearResolutionCacheByModule($identifier); - // 모듈별 캐시 키 - $cacheKeys = [ - "module.config.{$identifier}", - "module.info.{$identifier}", - "module.routes.{$identifier}", - "module.permissions.{$identifier}", - "module.menus.{$identifier}", - ]; - - foreach ($cacheKeys as $key) { - if ($this->cache->forget($key)) { - $clearedCount++; - } - } - - return $clearedCount; + return $this->layoutRepository + ->getBySourceIdentifier($identifier, LayoutSourceType::Module) + ->count(); } } diff --git a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php index 0dd0aa42..8e3bcb22 100644 --- a/app/Console/Commands/Plugin/ClearPluginCacheCommand.php +++ b/app/Console/Commands/Plugin/ClearPluginCacheCommand.php @@ -2,15 +2,21 @@ namespace App\Console\Commands\Plugin; -use App\Contracts\Extension\CacheInterface; -use App\Contracts\Repositories\PluginRepositoryInterface; +use App\Contracts\Repositories\LayoutRepositoryInterface; +use App\Enums\LayoutSourceType; +use App\Extension\ExtensionMiddlewareRegistry; use App\Extension\PluginManager; +use App\Extension\Traits\ClearsTemplateCaches; +use App\Extension\Traits\InvalidatesLayoutCache; use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; class ClearPluginCacheCommand extends Command { + use ClearsTemplateCaches; + use InvalidatesLayoutCache; + /** * The name and signature of the console command. */ @@ -27,8 +33,7 @@ class ClearPluginCacheCommand extends Command */ public function __construct( private PluginManager $pluginManager, - private PluginRepositoryInterface $pluginRepository, - private CacheInterface $cache, + protected LayoutRepositoryInterface $layoutRepository, private ExtensionBundleService $bundleService ) { parent::__construct(); @@ -36,6 +41,12 @@ class ClearPluginCacheCommand extends Command /** * Execute the console command. + * + * 실재하는 서버 캐시(플러그인 레이아웃 캐시 + 상태 키 + 미들웨어 인덱스 + * + 버전 포함 키)를 무효화한다. + * 종전 forget 대상(plugin.config.{id} 등)은 writer 가 없는 유령 키였다 (#588). + * 해석 캐시(layout_resolver.*)는 모듈 레이아웃 전용이라 플러그인은 비대상 + * (LayoutResolverService::resolve 의 fromModules 스코프는 plugin row 를 반환하지 않음). */ public function handle(): int { @@ -61,6 +72,11 @@ class ClearPluginCacheCommand extends Command $clearedCount = $this->clearPluginCache($identifier); + // 상태 키(ext.plugins.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump) + PluginManager::invalidatePluginStatusCache(); + ExtensionMiddlewareRegistry::flush(); + $this->incrementExtensionCacheVersion(); + $this->info('✅ '.__('plugins.commands.cache_clear.success_single', [ 'plugin' => $identifier, 'count' => $clearedCount, @@ -69,25 +85,16 @@ class ClearPluginCacheCommand extends Command // 모든 플러그인 캐시 삭제 $this->info(__('plugins.commands.cache_clear.clearing_all')); - // 전체 플러그인 캐시 키 삭제 - $cacheKeys = [ - 'plugins.all', - 'plugins.active', - 'plugins.installed', - ]; - - foreach ($cacheKeys as $key) { - if ($this->cache->forget($key)) { - $clearedCount++; - } - } - // 각 플러그인별 캐시 삭제 - $allPlugins = $this->pluginManager->getAllPlugins(); - foreach ($allPlugins as $pluginName => $plugin) { + foreach ($this->pluginManager->getAllPlugins() as $plugin) { $clearedCount += $this->clearPluginCache($plugin->getIdentifier()); } + // 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분 + PluginManager::invalidatePluginStatusCache(); + ExtensionMiddlewareRegistry::flush(); + $this->incrementExtensionCacheVersion(); + // 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제) $clearedCount += $this->bundleService->clearBundles('plugin'); @@ -113,25 +120,18 @@ class ClearPluginCacheCommand extends Command /** * 특정 플러그인의 캐시를 삭제합니다. + * + * 플러그인이 소유한 실재 서버 캐시는 레이아웃 캐시뿐이다 — 트레이트 단일 지점 + * (InvalidatesLayoutCache)으로 위임해 키 규약 드리프트를 방지한다. + * + * @return int 캐시를 무효화한 레이아웃 수 */ private function clearPluginCache(string $identifier): int { - $clearedCount = 0; + $this->invalidateExtensionLayoutCache($identifier, 'plugin'); - // 플러그인별 캐시 키 (메뉴 캐시 없음) - $cacheKeys = [ - "plugin.config.{$identifier}", - "plugin.info.{$identifier}", - "plugin.routes.{$identifier}", - "plugin.permissions.{$identifier}", - ]; - - foreach ($cacheKeys as $key) { - if ($this->cache->forget($key)) { - $clearedCount++; - } - } - - return $clearedCount; + return $this->layoutRepository + ->getBySourceIdentifier($identifier, LayoutSourceType::Plugin) + ->count(); } } diff --git a/app/Console/Commands/Template/ClearTemplateCacheCommand.php b/app/Console/Commands/Template/ClearTemplateCacheCommand.php index ca13ec43..cc52d257 100644 --- a/app/Console/Commands/Template/ClearTemplateCacheCommand.php +++ b/app/Console/Commands/Template/ClearTemplateCacheCommand.php @@ -2,16 +2,17 @@ namespace App\Console\Commands\Template; -use App\Contracts\Extension\CacheInterface; use App\Extension\TemplateManager; +use App\Extension\Traits\ClearsTemplateCaches; use App\Models\Template; -use App\Models\TemplateLayout; use App\Services\ExtensionBundleService; use Illuminate\Console\Command; use Illuminate\Support\Facades\Log; class ClearTemplateCacheCommand extends Command { + use ClearsTemplateCaches; + /** * The name and signature of the console command. */ @@ -28,7 +29,6 @@ class ClearTemplateCacheCommand extends Command */ public function __construct( private TemplateManager $templateManager, - private CacheInterface $cache, private ExtensionBundleService $bundleService ) { parent::__construct(); @@ -67,6 +67,10 @@ class ClearTemplateCacheCommand extends Command /** * 특정 템플릿의 캐시 삭제 + * + * 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점 + * (`TemplateManager::clearTemplateCache()`)을 경유한다 — 커맨드가 키 목록을 + * 별도로 유지하면 키 규약 변경 시 유령 forget 으로 사문화된다 (#588, 공개 #119). */ private function clearSingleTemplateCache(string $identifier): void { @@ -78,34 +82,12 @@ class ClearTemplateCacheCommand extends Command $this->info(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier])); - $clearedCount = 0; + // 고정 키(config/components_manifest) + 현재 버전 routes/language + 레이아웃 캐시 + $clearedCount = $this->templateManager->clearTemplateCache($identifier); - // 1. 레이아웃 캐시 삭제 - $templateRecord = Template::where('identifier', $identifier)->first(); - if ($templateRecord) { - $layouts = TemplateLayout::where('template_id', $templateRecord->id)->get(); - foreach ($layouts as $layout) { - $this->cache->forget("layout.{$identifier}.{$layout->name}"); - $clearedCount++; - } - } - - // 2. Routes 캐시 삭제 - $this->cache->forget("template.routes.{$identifier}"); - $clearedCount++; - - // 3. 다국어 파일 캐시 삭제 - $supportedLocales = config('app.supported_locales', ['ko', 'en']); - foreach ($supportedLocales as $locale) { - $this->cache->forget("template.language.{$identifier}.{$locale}"); - $clearedCount++; - } - - // 4. 활성 템플릿 타입 캐시 삭제 - if ($templateRecord) { - $this->cache->forget("templates.active.{$templateRecord->type}"); - $clearedCount++; - } + // 상태 키(ext.templates.*) + 버전 포함 키 무효화 (버전 bump) + TemplateManager::invalidateTemplateStatusCache(); + $this->incrementExtensionCacheVersion(); $this->info('✅ '.__('templates.commands.cache_clear.success_single', [ 'template' => $identifier, @@ -126,36 +108,17 @@ class ClearTemplateCacheCommand extends Command $this->info(__('templates.commands.cache_clear.clearing_all')); $clearedCount = 0; - $supportedLocales = config('app.supported_locales', ['ko', 'en']); - // 모든 설치된 템플릿의 캐시 삭제 - $templates = Template::all(); - - foreach ($templates as $templateRecord) { - // 1. 레이아웃 캐시 삭제 - $layouts = TemplateLayout::where('template_id', $templateRecord->id)->get(); - foreach ($layouts as $layout) { - $this->cache->forget("layout.{$templateRecord->identifier}.{$layout->name}"); - $clearedCount++; - } - - // 2. Routes 캐시 삭제 - $this->cache->forget("template.routes.{$templateRecord->identifier}"); - $clearedCount++; - - // 3. 다국어 파일 캐시 삭제 - foreach ($supportedLocales as $locale) { - $this->cache->forget("template.language.{$templateRecord->identifier}.{$locale}"); - $clearedCount++; - } + // 모든 설치된 템플릿의 캐시 삭제 (설치 레코드 기준 — 라이프사이클과 동일 지점) + foreach (Template::all() as $templateRecord) { + $clearedCount += $this->templateManager->clearTemplateCache($templateRecord->identifier); } - // 4. 활성 템플릿 타입 캐시 삭제 - $this->cache->forget('templates.active.admin'); - $this->cache->forget('templates.active.user'); - $clearedCount += 2; + // 상태 키 + 버전 포함 키 무효화는 전체에서 1회면 충분 + TemplateManager::invalidateTemplateStatusCache(); + $this->incrementExtensionCacheVersion(); - // 5. 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신) + // 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신) $clearedCount += $this->bundleService->clearBundles(); $this->info('✅ '.__('templates.commands.cache_clear.success_all', [ diff --git a/app/Extension/TemplateManager.php b/app/Extension/TemplateManager.php index 0baabb08..6f13c8c1 100644 --- a/app/Extension/TemplateManager.php +++ b/app/Extension/TemplateManager.php @@ -1687,13 +1687,9 @@ class TemplateManager implements TemplateManagerInterface } if (! in_array($override->name, $registeredLayoutNames)) { - // 캐시 삭제 (레코드 삭제 전에 수행) - $cacheKey = "template.{$templateId}.layout.{$override->name}"; - $this->cache()->forget($cacheKey); - - $sourceHash = md5($override->source_type?->value.$override->source_identifier); - $cacheKeyWithHash = "template.{$templateId}.layout.{$override->name}.{$sourceHash}"; - $this->cache()->forget($cacheKeyWithHash); + // 캐시 삭제 (레코드 삭제 전에 수행) — 키 조립은 트레이트 단일 지점에 위임 + // (.with_source_meta 변종 포함, 수작업 키 목록의 규약 드리프트 방지) + $this->forgetLayoutCacheKeys($override, $templateName); $override->forceDelete(); $deletedCount++; @@ -1740,14 +1736,10 @@ class TemplateManager implements TemplateManagerInterface } foreach ($overrideLayouts as $layout) { - // 기본 캐시 키 패턴으로 삭제 - $cacheKey = "template.{$templateId}.layout.{$layout->name}"; - $this->cache()->forget($cacheKey); - - // sourceHash를 포함한 캐시 키도 삭제 (LayoutService의 캐시 키 패턴) - $sourceHash = md5($layout->source_type?->value.$layout->source_identifier); - $cacheKeyWithHash = "template.{$templateId}.layout.{$layout->name}.{$sourceHash}"; - $this->cache()->forget($cacheKeyWithHash); + // 키 조립은 트레이트 단일 지점에 위임 (.with_source_meta 변종 포함). + // 이 경로는 identifier 를 보유하지 않으므로 '' 전달 — 버전 포함 공개 + // 서빙 키는 종전과 동일하게 버전 bump 로 무효화된다. + $this->forgetLayoutCacheKeys($layout, ''); } Log::info(__('templates.info.override_layouts_cache_invalidated'), [ @@ -1794,41 +1786,79 @@ class TemplateManager implements TemplateManagerInterface /** * 템플릿 관련 모든 캐시를 삭제합니다. * + * 템플릿 라이프사이클(update/deactivate/uninstall/cache-clear)이 공유하는 + * 무효화 단일 지점이다. 버전 접미사 없는 고정 키(`template.config.{identifier}`, + * `template.{id|identifier}.components_manifest`)는 캐시 버전 bump 로 무효화되지 + * 않으므로 여기서 능동 forget 한다 — 누락 시 공개 config.json 이 TTL(1시간) 동안 + * 이전 manifest 로 응답된다 (#588, 공개 #119). + * + * template:cache-clear 커맨드가 호출할 수 있도록 public 이다. + * * @param string $templateIdentifier 템플릿 식별자 + * @return int 능동 삭제를 시도한 캐시 키 수 */ - protected function clearTemplateCache(string $templateIdentifier): void + public function clearTemplateCache(string $templateIdentifier): int { + $clearedCount = 0; + + // identifier 만으로 가능한 고정 키 forget — uninstall 등 DB 레코드가 이미 + // 없는 상황에서도 반드시 수행되어야 하므로 레코드 조회보다 먼저 둔다. + $this->cache()->forget("template.config.{$templateIdentifier}"); + $clearedCount++; + + // components_manifest identifier 변종 (ComponentExists 는 int|string 수용) + $this->cache()->forget("template.{$templateIdentifier}.components_manifest"); + $clearedCount++; + + // 활성 템플릿 + 레이아웃 무변경 업데이트 경로는 캐시 버전 bump 가 없어 + // 현재 버전 routes/language 키가 stale 로 남는다 — warmTemplateCache() 와 + // 대칭으로 현재 버전 키를 능동 삭제한다 (이전 버전 키는 TTL 자연 만료). + $cacheVersion = self::getExtensionCacheVersion(); + $this->cache()->forget("template.routes.{$templateIdentifier}.v{$cacheVersion}"); + $clearedCount++; + foreach (config('app.supported_locales', ['ko', 'en']) as $locale) { + $this->cache()->forget("template.language.{$templateIdentifier}.{$locale}.v{$cacheVersion}"); + $clearedCount++; + } + // DB 기반 조회 — 파일 시스템 상태와 무관하게 캐시 삭제 보장 // (파일 교체 중이거나 reloadTemplate() 전에도 캐시를 확실히 삭제) $templateRecord = $this->templateRepository->findByIdentifier($templateIdentifier); if (! $templateRecord) { - return; + return $clearedCount; } - // 레이아웃 캐시 삭제 (버전 없는 내부 캐시) - $this->clearLayoutCaches($templateIdentifier); + // components_manifest 숫자 id 변종 (StoreLayoutRequest 가 integer 검증 — 실운영 키) + $this->cache()->forget("template.{$templateRecord->id}.components_manifest"); + $clearedCount++; - // Routes/다국어 캐시는 버전 포함 키이므로 incrementExtensionCacheVersion() + TTL로 무효화됨 + // 레이아웃 캐시 삭제 (버전 없는 내부 캐시) + $clearedCount += $this->clearLayoutCaches($templateIdentifier); Log::info(__('templates.info.cache_cleared'), [ 'template' => $templateIdentifier, ]); + + return $clearedCount; } /** * 템플릿의 모든 레이아웃 캐시를 삭제합니다. * * @param string $templateIdentifier 템플릿 식별자 + * @return int 캐시를 무효화한 레이아웃 수 */ - protected function clearLayoutCaches(string $templateIdentifier): void + protected function clearLayoutCaches(string $templateIdentifier): int { // 템플릿의 모든 레이아웃 조회 $template = $this->templateRepository->findByIdentifier($templateIdentifier); if (! $template) { - return; + return 0; } $this->invalidateTemplateLayoutCache($template->id, $templateIdentifier); + + return $this->layoutRepository->getByTemplateId($template->id)->count(); } /** @@ -3110,6 +3140,12 @@ class TemplateManager implements TemplateManagerInterface $onProgress?->__invoke('cleanup', '정리 중...'); ExtensionBackupHelper::deleteBackup($backupPath); + // 고정 키(config/components_manifest) + 현재 버전 routes/language 키 능동 삭제. + // 비활성 업데이트·레이아웃 무변경 업데이트 경로는 버전 bump 가 없어 이 호출이 + // 없으면 공개 config.json 이 TTL 동안 이전 manifest 로 응답된다 (#588, 공개 #119). + // 활성 경로의 refreshTemplateLayouts() 경유 중복 삭제는 forget 멱등이라 무해. + $this->clearTemplateCache($identifier); + $this->clearAllTemplateLanguageCaches(); $this->clearAllTemplateRoutesCaches(); // refreshTemplateLayouts() 내부에서 변경 시 incrementExtensionCacheVersion() 호출됨 diff --git a/app/Extension/Traits/InvalidatesLayoutCache.php b/app/Extension/Traits/InvalidatesLayoutCache.php index 57e5e704..ba4ba526 100644 --- a/app/Extension/Traits/InvalidatesLayoutCache.php +++ b/app/Extension/Traits/InvalidatesLayoutCache.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\Log; * 버전 없는 내부 캐시 키를 능동 삭제합니다: * 1. template.{templateId}.layout.{layoutName} - LayoutService에서 사용 * 2. template.{templateId}.layout.{layoutName}.{sourceHash} - 모듈/플러그인 레이아웃 + * 3. 위 두 형태의 `.with_source_meta` 접미사 변종 - 편집기용 병합 응답 캐시 * * 버전 포함 캐시 (layout.{identifier}.{name}.v{version})는 * incrementExtensionCacheVersion() + TTL로 무효화됩니다. @@ -98,20 +99,26 @@ trait InvalidatesLayoutCache $cache = $this->resolveLayoutCache(); // 1. LayoutService 내부 캐시 (버전 없음 → 능동 삭제) - $cache->forget("template.{$layout->template_id}.layout.{$layout->name}"); + // 편집기용 병합 응답은 `.with_source_meta` 접미사 키로 별도 캐싱되므로 + // (LayoutService::getMergedLayoutCacheKey) 두 변종을 함께 지운다 — + // 접미사 키를 남기면 activate/deactivate/uninstall/refresh 후에도 + // 편집기가 stale 병합 캐시를 받는다 (#588). + $cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name)); + $cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, withSourceMeta: true)); - // 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제) + // 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제) — 접미사는 소스 해시 뒤 if ($layout->source_type && $layout->source_identifier) { - $sourceHash = md5($layout->source_type->value.$layout->source_identifier); - $cache->forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}"); + $sourceType = $layout->source_type->value; + $cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier)); + $cache->forget($this->buildLayoutCacheKey($layout->template_id, $layout->name, $sourceType, $layout->source_identifier, true)); } // 3. PublicLayoutController 캐시 (버전 포함) — 일반 응답 + 편집기(`.meta`) 응답 두 키 모두. // PublicLayoutController::serve() 가 `with_source_meta=1`(레이아웃 편집기) 응답을 `.meta` // 접미사 별도 키로 캐싱하므로, 그 키를 함께 삭제하지 않으면 템플릿/레이아웃 상태 변화 // (refresh-layout / activate / deactivate / uninstall 등) 후에도 편집기가 stale 캐시를 - // 받는다. 레이아웃 - // 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를 지우므로 정합을 맞춘다. + // 받는다. 레이아웃 저장 경로(LayoutService::clearPublicServingCache)는 이미 두 키를 + // 지우므로 정합을 맞춘다. if ($templateIdentifier) { $cacheVersion = (int) $cache->get('ext.cache_version', 0); $cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}"); @@ -137,26 +144,32 @@ trait InvalidatesLayoutCache /** * 레이아웃 캐시 키를 생성합니다. * + * `$withSourceMeta` 가 true 면 `.with_source_meta` 접미사를 붙인다 — 편집기용 + * 병합 응답 키(LayoutService::getMergedLayoutCacheKey 와 동형, 접미사는 소스 해시 뒤). + * * @param int $templateId 템플릿 ID * @param string $layoutName 레이아웃 이름 * @param string|null $sourceType 소스 타입 (선택) * @param string|null $sourceIdentifier 소스 식별자 (선택) + * @param bool $withSourceMeta 편집기용 병합 응답 키 여부 (선택) * @return string 캐시 키 */ protected function buildLayoutCacheKey( int $templateId, string $layoutName, ?string $sourceType = null, - ?string $sourceIdentifier = null + ?string $sourceIdentifier = null, + bool $withSourceMeta = false ): string { $baseKey = "template.{$templateId}.layout.{$layoutName}"; + $metaSuffix = $withSourceMeta ? '.with_source_meta' : ''; if ($sourceType && $sourceIdentifier) { $sourceHash = md5($sourceType.$sourceIdentifier); - return "{$baseKey}.{$sourceHash}"; + return "{$baseKey}.{$sourceHash}{$metaSuffix}"; } - return $baseKey; + return "{$baseKey}{$metaSuffix}"; } } diff --git a/app/Http/Controllers/Api/Public/PublicLayoutController.php b/app/Http/Controllers/Api/Public/PublicLayoutController.php index 77cf1e68..cecd2d5e 100644 --- a/app/Http/Controllers/Api/Public/PublicLayoutController.php +++ b/app/Http/Controllers/Api/Public/PublicLayoutController.php @@ -3,10 +3,12 @@ namespace App\Http\Controllers\Api\Public; use App\Enums\ExtensionStatus; +use App\Extension\Traits\ClearsTemplateCaches; use App\Helpers\PermissionHelper; use App\Http\Controllers\Api\Base\PublicBaseController; use App\Services\LayoutService; use App\Services\TemplateService; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -17,6 +19,8 @@ use Illuminate\Http\Response; */ class PublicLayoutController extends PublicBaseController { + use ClearsTemplateCaches; + /** * 레이아웃 캐시 TTL (초) */ @@ -69,7 +73,10 @@ class PublicLayoutController extends PublicBaseController // 키만 forget 하므로 키 형식이 어긋나 무효화가 빗나간다(저장/복원 후 편집기 캔버스만 // stale). nonce 는 브라우저 HTTP 캐시 우회용(URL·ETag 차이로 이미 달성)이고, 서버 캐시 키 // 정합은 정수 버전이 SSoT 다. `(int)` 캐스팅은 PHP 가 소수점에서 절단해 정수부만 남긴다. - $cacheVersion = (int) request()->query('v', 0); + // `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도 + // 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588). + $rawVersion = request()->query('v'); + $cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion(); // 편집기 출처 메타 옵션 // - 옵션이 truthy 면 각 노드에 `__source` 메타를 부여한 응답을 반환 @@ -119,7 +126,7 @@ class PublicLayoutController extends PublicBaseController $mergedLayout, self::CACHE_TTL ); - } catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) { + } catch (ModelNotFoundException $e) { // 레이아웃 또는 부모 레이아웃을 찾을 수 없음 - 예외 메시지 전달 return $this->notFound($e->getMessage()); } diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php index a59747c6..b51cbd3b 100644 --- a/app/Http/Controllers/Api/Public/PublicTemplateController.php +++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php @@ -22,6 +22,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse; */ class PublicTemplateController extends PublicBaseController { + use ClearsTemplateCaches; + public function __construct( private TemplateService $templateService, private TemplateLayoutAttachmentService $layoutAttachmentService, @@ -40,8 +42,11 @@ class PublicTemplateController extends PublicBaseController // API 사용량 기록 $this->logApiUsage('templates.routes', ['identifier' => $identifier]); - // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화 - $cacheVersion = request()->query('v', 0); + // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화. + // `?v` 생략 시 현재 버전으로 폴백 — 리터럴 0 폴백은 워밍/무효화 어느 경로에도 + // 걸리지 않는 `.v0` 영구 사각 키를 만든다 (#588). `(int)` 캐스트로 키 위생 겸용. + $rawVersion = request()->query('v'); + $cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion(); // 확장 업데이트 중에는 활성 디렉토리가 잠시 비어 그 모듈의 라우트가 통째로 빠진다. // 그 순간의 응답을 버전 키에 캐시하면 업데이트가 끝난 뒤에도 캐시가 만료될 때까지 @@ -226,7 +231,7 @@ class PublicTemplateController extends PublicBaseController // 캐시 버전을 응답에 포함하여 프론트엔드가 API 호출 시 사용하도록 함 $responseData = $configData['data']; - $responseData['cache_version'] = ClearsTemplateCaches::getExtensionCacheVersion(); + $responseData['cache_version'] = self::getExtensionCacheVersion(); return $this->success( __('templates.messages.config_retrieved'), @@ -249,8 +254,10 @@ class PublicTemplateController extends PublicBaseController 'locale' => $locale, ]); - // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화 - $cacheVersion = request()->query('v', 0); + // 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화. + // `?v` 생략 시 현재 버전으로 폴백 (getRoutes 와 동일 — `.v0` 사각 키 방지) + $rawVersion = request()->query('v'); + $cacheVersion = $rawVersion !== null ? (int) $rawVersion : self::getExtensionCacheVersion(); // 캐싱된 응답 반환 (1시간 유효) $languageData = $this->cached( diff --git a/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php b/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php index 814dbd24..bdb67fe4 100644 --- a/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php +++ b/app/Listeners/LayoutEditor/MarkOrphanedCustomTranslations.php @@ -4,7 +4,7 @@ namespace App\Listeners\LayoutEditor; use App\Contracts\Extension\HookListenerInterface; use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface; -use App\Extension\Cache\CoreCacheDriver; +use App\Extension\Traits\ClearsTemplateCaches; use App\Services\LanguagePack\CustomTranslationUsageScanner; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Log; @@ -30,6 +30,8 @@ use Throwable; */ class MarkOrphanedCustomTranslations implements HookListenerInterface { + use ClearsTemplateCaches; + /** * @param TemplateCustomTranslationRepositoryInterface $repository 커스텀 키 리포지토리 * @param CustomTranslationUsageScanner $scanner 키 사용 스캐너 (순수) @@ -124,12 +126,11 @@ class MarkOrphanedCustomTranslations implements HookListenerInterface /** * 다국어 캐시 버전을 무효화합니다. * - * `ext.cache_version` 은 코어 소유 키이므로 컨테이너 바인딩에 의존하지 않고 - * 항상 CoreCacheDriver(`g7:core:` 네임스페이스)를 직접 생성합니다 - * (ClearsTemplateCaches 트레이트와 동일 SSoT). + * `ext.cache_version` 쓰기는 ClearsTemplateCaches 트레이트 단일 지점 경유 — + * 트레이트가 고정 CoreCacheDriver + 메모이즈 스토어로 write/read 일관성을 보장한다. */ private function invalidateLanguageCache(): void { - (new CoreCacheDriver(config('cache.default', 'array')))->put('ext.cache_version', time()); + $this->incrementExtensionCacheVersion(); } } diff --git a/app/Services/LayoutExtensionService.php b/app/Services/LayoutExtensionService.php index b143da21..3efc2c53 100644 --- a/app/Services/LayoutExtensionService.php +++ b/app/Services/LayoutExtensionService.php @@ -2,7 +2,6 @@ namespace App\Services; -use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\LayoutExtensionRepositoryInterface; use App\Contracts\Repositories\LayoutExtensionVersionRepositoryInterface; use App\Contracts\Repositories\LayoutRepositoryInterface; @@ -15,6 +14,7 @@ use App\Extension\HookManager; use App\Extension\ModuleManager; use App\Extension\PluginManager; use App\Extension\TemplateManager; +use App\Extension\Traits\ClearsTemplateCaches; use App\Extension\Traits\ComputesLayoutContentHash; use App\Models\LayoutExtension; use App\Models\TemplateLayoutExtensionVersion; @@ -32,6 +32,7 @@ use Illuminate\Support\Facades\Log; */ class LayoutExtensionService { + use ClearsTemplateCaches; use ComputesLayoutContentHash; /** @@ -123,7 +124,6 @@ class LayoutExtensionService private LayoutExtensionRepositoryInterface $repository, private LayoutExtensionVersionRepositoryInterface $versionRepository, private LayoutRepositoryInterface $layoutRepository, - private CacheInterface $cache, private PluginRepositoryInterface $pluginRepository, private ModuleRepositoryInterface $moduleRepository, ) {} @@ -1830,8 +1830,10 @@ class LayoutExtensionService } // extension_point 는 여러 레이아웃에서 사용될 수 있으므로 전역 캐시 버전 갱신 - // overlay 도 프론트엔드 브라우저 캐시 무효화를 위해 동일하게 갱신 - $this->cache->put('ext.cache_version', time()); + // overlay 도 프론트엔드 브라우저 캐시 무효화를 위해 동일하게 갱신. + // 쓰기는 트레이트 단일 지점(고정 CoreCacheDriver + 메모이즈 스토어) 경유 — + // 주입 CacheInterface 직접 put 은 재바인딩/스토어 상이 시 read 와 어긋난다. + $this->incrementExtensionCacheVersion(); } /** diff --git a/app/Services/LayoutService.php b/app/Services/LayoutService.php index e0f03b6e..4f764d2f 100644 --- a/app/Services/LayoutService.php +++ b/app/Services/LayoutService.php @@ -12,6 +12,7 @@ use App\Exceptions\CircularReferenceException; use App\Exceptions\ConcurrentModificationException; use App\Exceptions\LayoutIncludeException; use App\Extension\HookManager; +use App\Extension\Traits\ClearsTemplateCaches; use App\Helpers\PermissionHelper; use App\Models\TemplateLayout; use App\Models\User; @@ -21,6 +22,8 @@ use Illuminate\Support\Facades\Log; class LayoutService { + use ClearsTemplateCaches; + /** * LayoutRepository, LayoutVersionRepository, TemplateRepository, LayoutResolverService 및 LayoutExtensionService 주입 */ @@ -1470,7 +1473,9 @@ class LayoutService // 프론트엔드 브라우저 캐시 무효화를 위해 ext.cache_version 증가 // PublicLayoutController가 ?v={version} 기반 HTTP 캐시를 사용하므로 // 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다. - $this->cache->put('ext.cache_version', time()); + // 쓰기는 트레이트 단일 지점(고정 CoreCacheDriver + 메모이즈 스토어) 경유 — + // 주입 CacheInterface 직접 put 은 재바인딩/스토어 상이 시 read 와 어긋난다. + $this->incrementExtensionCacheVersion(); // After 훅 - 레이아웃 업데이트 후 HookManager::doAction('core.layout.after_update', $layout, $templateId, $name, $data); @@ -1579,8 +1584,8 @@ class LayoutService // 캐시 무효화 $this->clearDependentLayoutsCache($templateId, $name); - // 프론트엔드 브라우저 캐시 무효화 - $this->cache->put('ext.cache_version', time()); + // 프론트엔드 브라우저 캐시 무효화 (트레이트 단일 지점 경유) + $this->incrementExtensionCacheVersion(); // After 훅 - 버전 복원 후 HookManager::doAction('core.layout.after_version_restore', $newVersion, $templateId, $name, $versionId); diff --git a/app/Services/TemplateCustomTranslationService.php b/app/Services/TemplateCustomTranslationService.php index 66fed7ef..952c5c84 100644 --- a/app/Services/TemplateCustomTranslationService.php +++ b/app/Services/TemplateCustomTranslationService.php @@ -2,9 +2,9 @@ namespace App\Services; -use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface; use App\Exceptions\ConcurrentModificationException; +use App\Extension\Traits\ClearsTemplateCaches; use App\Models\TemplateCustomTranslation; use App\Services\LanguagePack\CustomTranslationUsageScanner; use Illuminate\Database\Eloquent\Collection; @@ -22,10 +22,12 @@ use Illuminate\Database\QueryException; * - seq 충돌 시 재시도 (unique 제약 위반 → seq 증가) * - 신규 키 생성 시 모든 활성 로케일에 폴백 시드 * - 낙관적 잠금 (`lock_version` — update 경로) - * - 커스텀 키 CRUD 시 다국어 캐시 무효화 + * - 커스텀 키 CRUD 시 다국어 캐시 무효화 */ class TemplateCustomTranslationService { + use ClearsTemplateCaches; + /** * seq 충돌 재시도 최대 횟수. */ @@ -33,11 +35,9 @@ class TemplateCustomTranslationService /** * @param TemplateCustomTranslationRepositoryInterface $repository 커스텀 다국어 키 리포지토리 - * @param CacheInterface $cache 캐시 드라이버 */ public function __construct( private readonly TemplateCustomTranslationRepositoryInterface $repository, - private readonly CacheInterface $cache, ) {} /** @@ -287,11 +287,12 @@ class TemplateCustomTranslationService * * `serveLanguage` 캐시 키가 `template.language.{id}.{locale}.v{cacheVersion}` * 형태로 `ext.cache_version` 을 포함하므로, 이 값을 갱신하면 다음 - * `loadTranslations` 가 재fetch 합니다. + * `loadTranslations` 가 재fetch 합니다. 쓰기는 트레이트 단일 지점 + * (고정 CoreCacheDriver + 메모이즈 스토어) 경유. */ private function invalidateLanguageCache(): void { - $this->cache->put('ext.cache_version', time()); + $this->incrementExtensionCacheVersion(); } /** diff --git a/docs/backend/api/templates.md b/docs/backend/api/templates.md index 3a25bdec..2f541657 100644 --- a/docs/backend/api/templates.md +++ b/docs/backend/api/templates.md @@ -97614,7 +97614,7 @@ HTTP/1.1 200 -**설명** 활성 템플릿의 설정 파일(template.json, error_config 등 메타데이터)을 서빙하는 공개 엔드포인트입니다. 응답에 확장 캐시 버전(`cache_version`)을 포함해 프론트엔드가 후속 API 호출에 사용하게 하며, 비활성/미존재 템플릿은 404입니다. 1시간 캐시됩니다. 인증이 필요 없습니다. +**설명** 활성 템플릿의 설정 파일(template.json, error_config 등 메타데이터)을 서빙하는 공개 엔드포인트입니다. 응답에 확장 캐시 버전(`cache_version`)을 포함해 프론트엔드가 후속 API 호출에 사용하게 하며, 비활성/미존재 템플릿은 404입니다. 1시간 캐시됩니다. 서버 캐시는 템플릿 라이프사이클(`template:update` / `template:cache-clear` / 비활성화 / 삭제) 시점에 즉시 무효화되어 최신 매니페스트가 반영됩니다. 인증이 필요 없습니다. ### GET /api/templates/{identifier}/editor-spec diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index dfcd3546..754ff3b9 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -181,7 +181,7 @@ php artisan template:install [identifier] [--force] php artisan template:activate [identifier] php artisan template:deactivate [identifier] php artisan template:uninstall [identifier] -php artisan template:cache-clear +php artisan template:cache-clear [identifier?] php artisan template:check-updates [identifier?] php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] [--source=auto|bundled|github] diff --git a/docs/extension/template-caching.md b/docs/extension/template-caching.md index d1fb7579..3349398f 100644 --- a/docs/extension/template-caching.md +++ b/docs/extension/template-caching.md @@ -34,23 +34,24 @@ ## 캐싱 계층 -### 1. 활성 템플릿 캐시 +### 1. 활성 템플릿 상태 캐시 ```php -// 키: templates.active.{type} -// TTL: 3600초 (1시간) -// 값: Template 모델 객체 +// 키: ext.templates.active_identifiers / ext.templates.active_identifiers_{type} / ext.templates.installed_identifiers +// TTL: extension_status_ttl 설정값 (기본 86400초) +// 값: 템플릿 식별자 목록 (CachesTemplateStatus 트레이트가 관리) -Cache::remember("templates.active.admin", 3600, function () { +$activeIdentifiers = $cache->remember('ext.templates.active_identifiers_admin', function () { return Template::where('type', 'admin') ->where('is_active', true) - ->first(); -}); + ->pluck('identifier') + ->all(); +}, $ttl); ``` **무효화 시점**: -- 템플릿 활성화/비활성화 시 -- 템플릿 삭제 시 +- 템플릿 설치/활성화/비활성화/삭제/업데이트 시 (`TemplateManager::invalidateTemplateStatusCache()` — 상태 키 4종 일괄 삭제) +- `template:cache-clear` 실행 시 --- @@ -93,22 +94,22 @@ Cache::remember("template.layout.merged.dashboard.ko", 3600, function () use ($l --- -### 4. 컴포넌트 레지스트리 캐시 +### 4. 컴포넌트 매니페스트 캐시 ```php -// 키: template.components.{identifier} -// TTL: 무제한 (수동 무효화) -// 값: components.json 파싱 결과 +// 키: template.{templateId}.components_manifest (숫자 id / identifier 두 변종) +// TTL: 3600초 (1시간) +// 값: components.json 파싱 결과 (레이아웃 컴포넌트 검증용) -Cache::rememberForever("template.components.sirsoft-admin_basic", function () use ($identifier) { +$manifest = $cache->remember("template.{$templateId}.components_manifest", function () use ($identifier) { $componentsPath = base_path("templates/{$identifier}/components.json"); return json_decode(file_get_contents($componentsPath), true); }); ``` **무효화 시점**: -- 템플릿 재설치 시 -- components.json 수정 시 (개발 중) +- 템플릿 라이프사이클 전 경로 (`TemplateManager::clearTemplateCache()` — update/deactivate/uninstall/`template:cache-clear`) +- components.json 이 바뀌는 템플릿 업데이트 시 (위 경로에 포함) --- @@ -326,11 +327,13 @@ use Illuminate\Support\Facades\Cache; class TemplateCacheService { /** - * 템플릿 활성화 캐시 무효화 + * 템플릿 상태 캐시 무효화 */ public function invalidateActiveTemplate(string $type): void { - Cache::forget("templates.active.{$type}"); + // 상태 키(ext.templates.active_identifiers* / installed_identifiers)는 + // CachesTemplateStatus 가 관리 — 단일 지점에 위임한다 + TemplateManager::invalidateTemplateStatusCache(); } /** @@ -363,11 +366,12 @@ class TemplateCacheService } /** - * 컴포넌트 레지스트리 캐시 무효화 + * 컴포넌트 매니페스트 캐시 무효화 (숫자 id / identifier 두 변종) */ - public function invalidateComponentRegistry(string $identifier): void + public function invalidateComponentRegistry(int $templateId, string $identifier): void { - Cache::forget("template.components.{$identifier}"); + Cache::forget("template.{$templateId}.components_manifest"); + Cache::forget("template.{$identifier}.components_manifest"); } /** @@ -387,8 +391,8 @@ class TemplateCacheService $this->invalidateLayoutChain($layout->layout_name); } - // 컴포넌트 레지스트리 캐시 무효화 - $this->invalidateComponentRegistry($identifier); + // 컴포넌트 매니페스트 캐시 무효화 + $this->invalidateComponentRegistry($template->id, $identifier); } } ``` @@ -460,7 +464,7 @@ class LayoutService `LayoutService`의 저장(`updateLayoutContent`) 및 버전 복원(`restoreVersion`) 메서드에서: 1. **`clearDependentLayoutsCache()`** 호출 — 해당 레이아웃 + 이를 extends하는 자식 레이아웃의 병합 캐시 + `PublicLayoutController` 서빙 캐시(`layout.{identifier}.{name}.v{version}`)를 재귀적으로 무효화 -2. **`extension_cache_version` 증가** (`Cache::put('extension_cache_version', time())`) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다. +2. **`ext.cache_version` 증가** (`$this->incrementExtensionCacheVersion()` — `ClearsTemplateCaches` 트레이트) — CDN/브라우저 HTTP 캐시 busting. `PublicLayoutController`가 `?v={version}` 기반 캐싱을 사용하므로, 버전 변경 시 브라우저가 새 URL로 인식하여 캐시를 우회합니다. 버전 키 쓰기는 반드시 트레이트를 경유합니다 — 캐시 드라이버에 직접 `put` 하면 스토어/네임스페이스가 어긋날 때 bump 가 읽기 경로에서 보이지 않습니다. ```php // LayoutService::updateLayoutContent() 내부 @@ -469,8 +473,8 @@ $layout = $this->layoutRepository->update($layout->id, $updateData); // 캐시 무효화 (현재 레이아웃 + 자식 레이아웃 + PublicLayoutController 서빙 캐시) $this->clearDependentLayoutsCache($templateId, $name); -// 프론트엔드 브라우저 캐시 무효화 -Cache::put('extension_cache_version', time()); +// 프론트엔드 브라우저 캐시 무효화 (ClearsTemplateCaches 트레이트) +$this->incrementExtensionCacheVersion(); ``` ```php @@ -479,12 +483,12 @@ $newVersion = $this->versionRepository->restoreVersion($layout->id, $versionId); // 동일하게 캐시 무효화 $this->clearDependentLayoutsCache($templateId, $name); -Cache::put('extension_cache_version', time()); +$this->incrementExtensionCacheVersion(); ``` 이 과정이 누락되면 편집/복원 내용이 실제 사이트에 반영되지 않습니다. -> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제 + `clearPublicServingCache()`(기본 v0 + 현재 extension_cache_version 캐시 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다. +> 참고: `clearDependentLayoutsCache()`는 내부적으로 `clearLayoutCache()`를 호출하며, 이 메서드는 병합 캐시 삭제(일반 + `.with_source_meta` 편집기 변종) + `clearPublicServingCache()`(현재 `ext.cache_version` 버전 키 삭제) + 모듈 레이아웃인 경우 `LayoutResolverService` 캐시도 무효화합니다. --- @@ -519,12 +523,31 @@ Cache::put('extension_cache_version', time()); ## 캐시 키 요약 -| 캐시 타입 | 키 패턴 | TTL | -|----------|---------|-----| -| 활성 템플릿 | `templates.active.{type}` | 3600초 (1시간) | -| 레이아웃 JSON | `template.layout.{name}.{locale}` | 1800초 (30분) | -| 병합된 레이아웃 | `template.layout.merged.{name}.{locale}` | 3600초 (1시간) | -| 컴포넌트 레지스트리 | `template.components.{identifier}` | 무제한 | +서버 사이드 캐시는 모두 `CacheInterface`(`g7:core:` 네임스페이스) 에 저장됩니다. 무효화 방식은 두 갈래입니다 — **버전 키**(`.v{v}` 접미사)는 `ext.cache_version` bump 로 다음 요청부터 새 키를 쓰고 이전 키는 TTL 로 자연 만료되며, **고정 키**(버전 접미사 없음)는 라이프사이클에서 능동 `forget` 해야 합니다. + +| 캐시 타입 | 키 패턴 | TTL | 무효화 | +|----------|---------|-----|--------| +| 공개 템플릿 설정 (config.json) | `template.config.{identifier}` | 3600초 | 고정 키 — 라이프사이클 능동 삭제 | +| 공개 라우트 (routes.json) | `template.routes.{identifier}.v{v}` | 3600초 | 버전 키 + 현재 버전 키 능동 삭제 | +| 공개 다국어 (lang/{locale}.json) | `template.language.{identifier}.{locale}.v{v}` | 3600초 | 버전 키 + 현재 버전 키 능동 삭제 | +| 공개 레이아웃 서빙 | `layout.{identifier}.{name}.v{v}` (+ 편집기 `.meta` 변종) | layout_ttl (기본 3600초) | 버전 키 + 현재 버전 키 능동 삭제 | +| 병합 레이아웃 (내부) | `template.{templateId}.layout.{name}` (+ `.{sourceHash}` / `.with_source_meta` 변종) | layout_ttl | 고정 키 — 능동 삭제 | +| 컴포넌트 매니페스트 (검증) | `template.{templateId}.components_manifest` (identifier 변종 포함) | 3600초 | 고정 키 — 능동 삭제 | +| 템플릿 상태 | `ext.templates.active_identifiers*` / `ext.templates.installed_identifiers` | 86400초 | 라이프사이클 능동 삭제 | +| 확장 캐시 버전 좌표 | `ext.cache_version` | 없음 | bump 전용 (`ClearsTemplateCaches`) | + +### `template:cache-clear` 가 무효화하는 대상 + +`template:cache-clear {identifier?}` 는 템플릿 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점을 경유합니다. + +| 대상 | 단일 모드 | 전체 모드 | +|------|-----------|-----------| +| `template.config.{identifier}` | ✅ | ✅ (설치된 전 템플릿) | +| `template.{id}.components_manifest` (identifier 변종 포함) | ✅ | ✅ | +| 현재 버전 routes/language 키 | ✅ | ✅ | +| 병합 레이아웃 캐시 (변종 포함) | ✅ | ✅ | +| 템플릿 상태 키 + `ext.cache_version` bump | ✅ | ✅ (1회) | +| 확장 프론트엔드 병합 번들 파일 | — | ✅ | --- diff --git a/docs/extension/template-commands.md b/docs/extension/template-commands.md index fe20e496..4b6a681c 100644 --- a/docs/extension/template-commands.md +++ b/docs/extension/template-commands.md @@ -79,7 +79,7 @@ php artisan template:activate [vendor-template] **기능**: - `templates.is_active` 플래그 설정 - 동일 타입의 기존 활성 템플릿 비활성화 -- 캐시 무효화 (`templates.active.{type}`) +- 상태 캐시 무효화 (`ext.templates.active_identifiers*` / `ext.templates.installed_identifiers`) --- diff --git a/tests/Feature/Api/Public/LayoutServingTest.php b/tests/Feature/Api/Public/LayoutServingTest.php index 5e159a8c..2210e997 100644 --- a/tests/Feature/Api/Public/LayoutServingTest.php +++ b/tests/Feature/Api/Public/LayoutServingTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Api\Public; +use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Models\Template; use App\Models\TemplateLayout; @@ -210,9 +211,13 @@ class LayoutServingTest extends TestCase ], ]); - // PublicLayoutController 는 CacheInterface + 버전 키 ("layout.{id}.{name}.v{v}") 사용 - $cache = app(\App\Contracts\Extension\CacheInterface::class); - $cacheKey = "layout.{$template->identifier}.{$layout->name}.v0"; + // PublicLayoutController 는 CacheInterface + 버전 키 ("layout.{id}.{name}.v{v}") 사용. + // `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지) + // 버전을 시드해 결정적 키로 검증한다 + Cache::put('g7:core:ext.cache_version', 1234); + + $cache = app(CacheInterface::class); + $cacheKey = "layout.{$template->identifier}.{$layout->name}.v1234"; $cache->forget($cacheKey); // Act: 첫 번째 요청 (캐시 미스, DB 조회) diff --git a/tests/Feature/Console/Commands/Module/ModuleArtisanCommandsTest.php b/tests/Feature/Console/Commands/Module/ModuleArtisanCommandsTest.php index 8e52de93..dfb1a5f3 100644 --- a/tests/Feature/Console/Commands/Module/ModuleArtisanCommandsTest.php +++ b/tests/Feature/Console/Commands/Module/ModuleArtisanCommandsTest.php @@ -4,10 +4,17 @@ namespace Tests\Feature\Console\Commands\Module; use App\Enums\ExtensionOwnerType; use App\Enums\ExtensionStatus; +use App\Enums\LayoutSourceType; +use App\Extension\Cache\CoreCacheDriver; +use App\Extension\ExtensionMiddlewareRegistry; use App\Extension\ModuleManager; +use App\Extension\Traits\ClearsTemplateCaches; use App\Models\Module; use App\Models\Permission; +use App\Models\Template; +use App\Models\TemplateLayout; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Tests\Helpers\ProtectsExtensionDirectories; use Tests\TestCase; @@ -163,24 +170,97 @@ class ModuleArtisanCommandsTest extends TestCase /** * module:cache-clear 커맨드가 모든 캐시를 삭제하는지 테스트 + * + * 실재하는 상태 키(ext.modules.*_identifiers)가 소멸하고 캐시 버전이 bump 되는지 + * 검증한다 (#588 — 기존 forget 대상은 writer 없는 유령 키였다). */ public function test_cache_clear_command_clears_all_caches(): void { + $coreCache = new CoreCacheDriver(config('cache.default', 'array')); + $coreCache->put('ext.modules.active_identifiers', ['stale-module'], 3600); + $coreCache->put('ext.modules.installed_identifiers', ['stale-module'], 3600); + // 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함 + // remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다 + $coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]); + Cache::put('g7:core:ext.cache_version', 1000); + $this->artisan('module:cache-clear') ->expectsOutput(__('modules.commands.cache_clear.clearing_all')) ->assertExitCode(0); + + $this->assertNull( + $coreCache->get('ext.modules.active_identifiers'), + 'module:cache-clear 후 활성 모듈 상태 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $coreCache->get('ext.modules.installed_identifiers'), + 'module:cache-clear 후 설치 모듈 상태 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $coreCache->get('extension.middleware_index'), + 'module:cache-clear 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.' + ); + $this->assertGreaterThan( + 1000, + ClearsTemplateCaches::getExtensionCacheVersion(), + 'module:cache-clear 후 확장 캐시 버전이 bump 되어야 합니다.' + ); } /** * module:cache-clear 커맨드가 특정 모듈의 캐시만 삭제하는지 테스트 + * + * 해석 캐시(layout_resolver.{templateId}.{name})는 버전 접미사 없는 고정 키로 + * 레이아웃 row ID 를 저장하므로, 커맨드가 능동 삭제하지 않으면 TTL 동안 + * 이전 해석 결과가 유지된다 (#588 동종 보강). */ public function test_cache_clear_command_clears_single_module_cache(): void { $identifier = 'sirsoft-board'; + // 모듈 소유 레이아웃 row — 해석 캐시 키 도출(getLayoutsByModule)의 원천 + $template = Template::factory()->create([ + 'identifier' => 'cachetest-admin_basic', + 'type' => 'admin', + 'status' => ExtensionStatus::Active->value, + ]); + TemplateLayout::create([ + 'template_id' => $template->id, + 'name' => 'sirsoft-board.cache_probe', + 'source_type' => LayoutSourceType::Module->value, + 'source_identifier' => $identifier, + 'content' => ['meta' => ['title' => 'probe'], 'components' => []], + ]); + + $coreCache = new CoreCacheDriver(config('cache.default', 'array')); + $coreCache->put('ext.modules.active_identifiers', ['stale-module'], 3600); + $coreCache->put("layout_resolver.{$template->id}.sirsoft-board.cache_probe", 12345, 3600); + // 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함 + // remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다 + $coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]); + Cache::put('g7:core:ext.cache_version', 1000); + $this->artisan('module:cache-clear', ['identifier' => $identifier]) ->expectsOutput(__('modules.commands.cache_clear.clearing_single', ['module' => $identifier])) ->assertExitCode(0); + + $this->assertNull( + $coreCache->get('ext.modules.active_identifiers'), + 'module:cache-clear(단일) 후 모듈 상태 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $coreCache->get("layout_resolver.{$template->id}.sirsoft-board.cache_probe"), + 'module:cache-clear(단일) 후 해당 모듈 레이아웃의 해석 캐시(layout_resolver.*)가 삭제되어야 합니다.' + ); + $this->assertNull( + $coreCache->get('extension.middleware_index'), + 'module:cache-clear(단일) 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.' + ); + $this->assertGreaterThan( + 1000, + ClearsTemplateCaches::getExtensionCacheVersion(), + 'module:cache-clear(단일) 후 확장 캐시 버전이 bump 되어야 합니다.' + ); } /** diff --git a/tests/Feature/Console/Commands/Plugin/PluginArtisanCommandsTest.php b/tests/Feature/Console/Commands/Plugin/PluginArtisanCommandsTest.php index 7bd285cc..b4932d10 100644 --- a/tests/Feature/Console/Commands/Plugin/PluginArtisanCommandsTest.php +++ b/tests/Feature/Console/Commands/Plugin/PluginArtisanCommandsTest.php @@ -4,9 +4,13 @@ namespace Tests\Feature\Console\Commands\Plugin; use App\Enums\ExtensionOwnerType; use App\Enums\ExtensionStatus; +use App\Extension\Cache\CoreCacheDriver; +use App\Extension\ExtensionMiddlewareRegistry; use App\Extension\PluginManager; +use App\Extension\Traits\ClearsTemplateCaches; use App\Models\Permission; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\File; use Tests\Helpers\ProtectsExtensionDirectories; use Tests\TestCase; @@ -183,12 +187,41 @@ class PluginArtisanCommandsTest extends TestCase /** * plugin:cache-clear 커맨드가 모든 캐시를 삭제하는지 테스트 + * + * 실재하는 상태 키(ext.plugins.*_identifiers)가 소멸하고 캐시 버전이 bump 되는지 + * 검증한다 (#588 — 기존 forget 대상은 writer 없는 유령 키였다). */ public function test_cache_clear_command_clears_all_caches(): void { + $coreCache = new CoreCacheDriver(config('cache.default', 'array')); + $coreCache->put('ext.plugins.active_identifiers', ['stale-plugin'], 3600); + $coreCache->put('ext.plugins.installed_identifiers', ['stale-plugin'], 3600); + // 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함 + // remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다 + $coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]); + Cache::put('g7:core:ext.cache_version', 1000); + $this->artisan('plugin:cache-clear') ->expectsOutput(__('plugins.commands.cache_clear.clearing_all')) ->assertExitCode(0); + + $this->assertNull( + $coreCache->get('ext.plugins.active_identifiers'), + 'plugin:cache-clear 후 활성 플러그인 상태 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $coreCache->get('ext.plugins.installed_identifiers'), + 'plugin:cache-clear 후 설치 플러그인 상태 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $coreCache->get('extension.middleware_index'), + 'plugin:cache-clear 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.' + ); + $this->assertGreaterThan( + 1000, + ClearsTemplateCaches::getExtensionCacheVersion(), + 'plugin:cache-clear 후 확장 캐시 버전이 bump 되어야 합니다.' + ); } /** @@ -198,9 +231,30 @@ class PluginArtisanCommandsTest extends TestCase { $identifier = 'sirsoft-daum_postcode'; + $coreCache = new CoreCacheDriver(config('cache.default', 'array')); + $coreCache->put('ext.plugins.active_identifiers', ['stale-plugin'], 3600); + // 실제 writer(ExtensionMiddlewareRegistry::buildIndex)와 동형으로 태그 포함 + // remember — flush() 는 taggable 스토어에서 태그 인덱스 기반으로 삭제한다 + $coreCache->remember('extension.middleware_index', fn () => [['stale' => true]], 3600, [ExtensionMiddlewareRegistry::CACHE_TAG]); + Cache::put('g7:core:ext.cache_version', 1000); + $this->artisan('plugin:cache-clear', ['identifier' => $identifier]) ->expectsOutput(__('plugins.commands.cache_clear.clearing_single', ['plugin' => $identifier])) ->assertExitCode(0); + + $this->assertNull( + $coreCache->get('ext.plugins.active_identifiers'), + 'plugin:cache-clear(단일) 후 플러그인 상태 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $coreCache->get('extension.middleware_index'), + 'plugin:cache-clear(단일) 후 확장 미들웨어 인덱스 캐시(extension.middleware_index)가 삭제되어야 합니다.' + ); + $this->assertGreaterThan( + 1000, + ClearsTemplateCaches::getExtensionCacheVersion(), + 'plugin:cache-clear(단일) 후 확장 캐시 버전이 bump 되어야 합니다.' + ); } /** diff --git a/tests/Feature/Console/Commands/Template/TemplateArtisanCommandsTest.php b/tests/Feature/Console/Commands/Template/TemplateArtisanCommandsTest.php index be2f464e..5cd13b55 100644 --- a/tests/Feature/Console/Commands/Template/TemplateArtisanCommandsTest.php +++ b/tests/Feature/Console/Commands/Template/TemplateArtisanCommandsTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Console\Commands\Template; use App\Enums\ExtensionStatus; +use App\Extension\Cache\CoreCacheDriver; use App\Extension\TemplateManager; use App\Models\Template; use App\Models\TemplateLayout; @@ -366,20 +367,41 @@ class TemplateArtisanCommandsTest extends TestCase $this->artisan('template:list', ['--status' => 'inactive']) ->assertExitCode(0); - // cache-clear 커맨드 (특정 템플릿) + // cache-clear 커맨드 (특정 템플릿) — 공개 config 캐시 실소멸까지 검증 (#588, 공개 #119) + $coreCache = new CoreCacheDriver(config('cache.default', 'array')); + $coreCache->put("template.config.{$identifier}", [ + 'success' => true, + 'data' => ['version' => '0.0.1-stale'], + ], 3600); + $this->artisan('template:cache-clear', ['identifier' => $identifier]) ->expectsOutput(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier])) ->assertExitCode(0); + $this->assertNull( + $coreCache->get("template.config.{$identifier}"), + 'template:cache-clear(단일) 후 공개 config 캐시가 삭제되어야 합니다.' + ); + // 활성화하여 cache-clear 전체 테스트 $this->artisan('template:activate', ['identifier' => $identifier]) ->assertExitCode(0); - // cache-clear 커맨드 (전체) + // cache-clear 커맨드 (전체) — 전체 모드도 config 캐시 실소멸 검증 + $coreCache->put("template.config.{$identifier}", [ + 'success' => true, + 'data' => ['version' => '0.0.1-stale'], + ], 3600); + $this->artisan('template:cache-clear') ->expectsOutput(__('templates.commands.cache_clear.clearing_all')) ->assertExitCode(0); + $this->assertNull( + $coreCache->get("template.config.{$identifier}"), + 'template:cache-clear(전체) 후 공개 config 캐시가 삭제되어야 합니다.' + ); + // 비활성화 $this->artisan('template:deactivate', ['identifier' => $identifier]) ->assertExitCode(0); diff --git a/tests/Feature/Template/PublicLayoutControllerTest.php b/tests/Feature/Template/PublicLayoutControllerTest.php index 12e89314..14833fb4 100644 --- a/tests/Feature/Template/PublicLayoutControllerTest.php +++ b/tests/Feature/Template/PublicLayoutControllerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Template; +use App\Contracts\Extension\CacheInterface; use App\Models\Permission; use App\Models\Role; use App\Models\Template; @@ -203,9 +204,13 @@ class PublicLayoutControllerTest extends TestCase // 캐시 초기화 — CacheInterface 를 통해 코어 캐시 prefix 일관 사용 // (Laravel Cache facade 의 global `CACHE_PREFIX` 와 CoreCacheDriver 의 - // `g7:core` prefix 가 합쳐진 실제 키와 동등하게 조회/삭제하기 위함) - $coreCache = app(\App\Contracts\Extension\CacheInterface::class); - $coreCache->forget("layout.{$template->identifier}.{$layout->name}.v0"); + // `g7:core` prefix 가 합쳐진 실제 키와 동등하게 조회/삭제하기 위함). + // `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지) + // 버전을 시드해 결정적 키로 검증한다 + Cache::put('g7:core:ext.cache_version', 1234); + + $coreCache = app(CacheInterface::class); + $coreCache->forget("layout.{$template->identifier}.{$layout->name}.v1234"); $coreCache->forget("template.{$template->id}.layout.{$layout->name}"); // Act: 첫 번째 요청 (캐시 생성) @@ -214,7 +219,7 @@ class PublicLayoutControllerTest extends TestCase // 캐시가 생성되었는지 CacheInterface 로 확인 (컨트롤러가 사용하는 동일 경로) $this->assertTrue( - $coreCache->has("layout.{$template->identifier}.{$layout->name}.v0"), + $coreCache->has("layout.{$template->identifier}.{$layout->name}.v1234"), '레이아웃 응답이 CoreCacheDriver(g7:core 접두사) 에 캐시되어야 함', ); @@ -252,7 +257,7 @@ class PublicLayoutControllerTest extends TestCase 'content' => ['meta' => ['title' => 'Dashboard'], 'data_sources' => [], 'components' => []], ]); - $coreCache = app(\App\Contracts\Extension\CacheInterface::class); + $coreCache = app(CacheInterface::class); $version = 1781151505; $nonce = 7; // 편집 모드(.meta) — `core.templates.layouts.edit` 권한 보유 사용자로 인증. diff --git a/tests/Feature/Template/PublicTemplateControllerTest.php b/tests/Feature/Template/PublicTemplateControllerTest.php index 7b6b8b51..7fe04470 100644 --- a/tests/Feature/Template/PublicTemplateControllerTest.php +++ b/tests/Feature/Template/PublicTemplateControllerTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Template; +use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Models\Template; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -141,9 +142,12 @@ class PublicTemplateControllerTest extends TestCase 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], ]); - // 캐시 초기화 (CacheInterface 사용 — v{version} 접미사 포함, 기본 v=0) - $cache = app(\App\Contracts\Extension\CacheInterface::class); - $cacheKey = "template.routes.{$template->identifier}.v0"; + // `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지) + // 버전을 시드해 결정적 키로 검증한다 (getExtensionCacheVersion 은 0 을 반환하지 않음) + Cache::put('g7:core:ext.cache_version', 1234); + + $cache = app(CacheInterface::class); + $cacheKey = "template.routes.{$template->identifier}.v1234"; $cache->forget($cacheKey); // Act: 첫 번째 요청 (캐시 생성) @@ -160,4 +164,98 @@ class PublicTemplateControllerTest extends TestCase // 두 응답의 데이터가 동일한지 확인 $this->assertEquals($response1->json('data'), $response2->json('data')); } + + /** + * config.json 서빙이 캐시를 생성하고 installed manifest 버전을 반환하는지 테스트 (#588, 공개 #119) + * + * @scenario cache_state=cold,template_type=admin + * + * @effects serve_config_caches_manifest_and_embeds_cache_version + */ + public function test_serve_config_caches_manifest_and_returns_version(): void + { + // Arrange: 활성화된 템플릿 생성 (실제 번들 파일을 읽는 경량 픽스처) + $template = Template::create([ + 'identifier' => 'sirsoft-admin_basic', + 'vendor' => 'sirsoft', + 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], + 'version' => '1.0.0', + 'type' => 'admin', + 'status' => ExtensionStatus::Active->value, + 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], + ]); + + $cache = app(CacheInterface::class); + $cacheKey = "template.config.{$template->identifier}"; + $cache->forget($cacheKey); + + // Act + $response = $this->getJson("/api/templates/{$template->identifier}/config.json"); + + // Assert: 성공 + installed template.json 의 실제 version 반환 + $response->assertStatus(200)->assertJson(['success' => true]); + + $manifest = json_decode( + file_get_contents(base_path("templates/{$template->identifier}/template.json")), + true + ); + $this->assertSame($manifest['version'], $response->json('data.version')); + + // cache_version 은 캐시 read 후 현재값이 주입되는 유효 정수 + $this->assertIsInt($response->json('data.cache_version')); + $this->assertGreaterThan(0, $response->json('data.cache_version')); + + // 서버 캐시 키가 생성됨 + $this->assertNotNull($cache->get($cacheKey), 'config 조회 후 서버 캐시가 생성되어야 합니다.'); + } + + /** + * config.json 캐시 삭제 후 최신 manifest 가 반환되는지 테스트 (#588, 공개 #119) + * + * 캐시 히트 증명(변조본 서빙) → forget → 실제 파일 버전 복귀 체인. + * 라이브 재현(sentinel 주입 → cache-clear 무효 → 직접 forget 으로만 해소)의 자동화판. + * + * @scenario cache_state=warm_stale,template_type=admin + * + * @effects serve_config_returns_installed_manifest_after_invalidation + */ + public function test_serve_config_returns_fresh_manifest_after_cache_cleared(): void + { + // Arrange + $template = Template::create([ + 'identifier' => 'sirsoft-admin_basic', + 'vendor' => 'sirsoft', + 'name' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], + 'version' => '1.0.0', + 'type' => 'admin', + 'status' => ExtensionStatus::Active->value, + 'description' => ['ko' => '기본 관리자 템플릿', 'en' => 'Basic Admin Template'], + ]); + + $cache = app(CacheInterface::class); + $cacheKey = "template.config.{$template->identifier}"; + + // 캐시에 변조된 manifest 심기 (파일 무수정 — stale 상태 재현) + $cache->put($cacheKey, [ + 'success' => true, + 'data' => ['version' => '9.9.9-stale-sentinel'], + ], 3600); + + // Act & Assert 1: 캐시 히트 경로 증명 — 변조본이 그대로 서빙됨 + $stale = $this->getJson("/api/templates/{$template->identifier}/config.json"); + $stale->assertStatus(200); + $this->assertSame('9.9.9-stale-sentinel', $stale->json('data.version')); + + // Act & Assert 2: forget 후 실제 파일 버전 복귀 + $cache->forget($cacheKey); + + $fresh = $this->getJson("/api/templates/{$template->identifier}/config.json"); + $fresh->assertStatus(200); + + $manifest = json_decode( + file_get_contents(base_path("templates/{$template->identifier}/template.json")), + true + ); + $this->assertSame($manifest['version'], $fresh->json('data.version')); + } } diff --git a/tests/Feature/Template/TemplateCacheManagementTest.php b/tests/Feature/Template/TemplateCacheManagementTest.php index 22672db2..2d69c546 100644 --- a/tests/Feature/Template/TemplateCacheManagementTest.php +++ b/tests/Feature/Template/TemplateCacheManagementTest.php @@ -284,6 +284,196 @@ class TemplateCacheManagementTest extends TestCase } } + /** + * template:cache-clear 커맨드가 공개 config 캐시를 삭제하는지 테스트 (#588, 공개 #119) + * + * `template.config.{identifier}` 는 버전 접미사 없는 고정 키라 캐시 버전 bump 로 + * 무효화되지 않는다 — 커맨드가 능동 forget 하지 않으면 TTL(1시간) 동안 + * 이전 manifest 가 계속 서빙된다. + * + * @scenario cache_state=warm_stale,lifecycle_trigger=cache_clear_command_single,template_type=admin + * + * @effects cache_clear_command_forgets_template_config_key + */ + public function test_template_cache_clear_command_forgets_public_config_cache(): void + { + // Arrange + $this->templateManager->installTemplate('sirsoft-admin_basic'); + + $this->coreCache()->put('template.config.sirsoft-admin_basic', [ + 'success' => true, + 'data' => ['version' => '0.0.1-stale'], + ], 3600); + + // Act + $this->artisan('template:cache-clear', ['identifier' => 'sirsoft-admin_basic']) + ->assertExitCode(0); + + // Assert + $this->assertNull( + $this->coreCache()->get('template.config.sirsoft-admin_basic'), + 'template:cache-clear 후 공개 config 캐시(template.config.{identifier})가 삭제되어야 합니다.' + ); + } + + /** + * template:cache-clear 전체 모드가 공개 config 캐시를 삭제하는지 테스트 (#588, 공개 #119) + * + * @scenario cache_state=warm_stale,lifecycle_trigger=cache_clear_command_all,template_type=admin + * + * @effects cache_clear_command_forgets_template_config_key + */ + public function test_template_cache_clear_all_forgets_public_config_cache(): void + { + // Arrange + $this->templateManager->installTemplate('sirsoft-admin_basic'); + + $this->coreCache()->put('template.config.sirsoft-admin_basic', [ + 'success' => true, + 'data' => ['version' => '0.0.1-stale'], + ], 3600); + + // Act (무인자 = 전체 모드) + $this->artisan('template:cache-clear') + ->assertExitCode(0); + + // Assert + $this->assertNull( + $this->coreCache()->get('template.config.sirsoft-admin_basic'), + 'template:cache-clear 전체 모드 후 공개 config 캐시가 삭제되어야 합니다.' + ); + } + + /** + * clearTemplateCache() 가 config + components_manifest 고정 키를 삭제하는지 테스트 (#588) + * + * components_manifest 는 숫자 id / identifier 두 변종 키가 실재한다 + * (ComponentExists 는 int|string 을 수용). 템플릿 업데이트로 components.json 이 + * 변해도 이 키가 남으면 최대 1시간 구 매니페스트로 레이아웃을 검증한다. + * + * @scenario cache_state=warm_stale,lifecycle_trigger=template_update,template_type=admin + * + * @effects template_update_invalidates_public_config_cache,clear_template_cache_forgets_components_manifest_variants + */ + public function test_clear_template_cache_forgets_config_and_components_manifest(): void + { + // Arrange + $this->templateManager->installTemplate('sirsoft-admin_basic'); + $template = Template::where('identifier', 'sirsoft-admin_basic')->first(); + + $this->coreCache()->put('template.config.sirsoft-admin_basic', [ + 'success' => true, + 'data' => ['version' => '0.0.1-stale'], + ], 3600); + $this->coreCache()->put("template.{$template->id}.components_manifest", ['components' => []], 3600); + $this->coreCache()->put('template.sirsoft-admin_basic.components_manifest', ['components' => []], 3600); + + // Act — 라이프사이클(update/deactivate/uninstall/cache-clear)이 공유하는 무효화 단일 지점 + $this->templateManager->clearTemplateCache('sirsoft-admin_basic'); + + // Assert + $this->assertNull( + $this->coreCache()->get('template.config.sirsoft-admin_basic'), + 'clearTemplateCache() 후 공개 config 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $this->coreCache()->get("template.{$template->id}.components_manifest"), + 'clearTemplateCache() 후 숫자 id 변종 components_manifest 캐시가 삭제되어야 합니다.' + ); + $this->assertNull( + $this->coreCache()->get('template.sirsoft-admin_basic.components_manifest'), + 'clearTemplateCache() 후 identifier 변종 components_manifest 캐시가 삭제되어야 합니다.' + ); + } + + /** + * 비활성화/삭제 라이프사이클이 공개 config 캐시를 삭제하는지 테스트 (#588, 공개 #119) + * + * 활성 시절 warm 된 config 캐시가 남으면 비활성/삭제된 템플릿의 manifest 를 + * 최대 1시간 200 으로 계속 서빙한다 (캐시 히트가 상태 검사보다 먼저). + * + * @scenario cache_state=warm_stale,lifecycle_trigger=deactivate,template_type=admin + * + * @effects deactivate_uninstall_invalidate_public_config_cache + */ + public function test_template_deactivate_and_uninstall_invalidate_public_config_cache(): void + { + // Arrange + $this->templateManager->installTemplate('sirsoft-admin_basic'); + $this->templateManager->activateTemplate('sirsoft-admin_basic'); + + $warmConfig = function (): void { + $this->coreCache()->put('template.config.sirsoft-admin_basic', [ + 'success' => true, + 'data' => ['version' => '0.0.1-stale'], + ], 3600); + }; + + // Act & Assert — 비활성화 경로 + $warmConfig(); + $this->templateManager->deactivateTemplate('sirsoft-admin_basic'); + $this->assertNull( + $this->coreCache()->get('template.config.sirsoft-admin_basic'), + '비활성화 후 공개 config 캐시가 삭제되어야 합니다.' + ); + + // Act & Assert — 삭제 경로 + $warmConfig(); + $this->templateManager->uninstallTemplate('sirsoft-admin_basic'); + $this->assertNull( + $this->coreCache()->get('template.config.sirsoft-admin_basic'), + '삭제 후 공개 config 캐시가 삭제되어야 합니다.' + ); + } + + /** + * 비활성화 시 편집기용 `.with_source_meta` 변종 레이아웃 캐시도 삭제되는지 테스트 (#588) + * + * LayoutService 는 편집기 병합 응답을 `.with_source_meta` 접미사 키로 별도 캐싱한다. + * 라이프사이클 무효화(forgetLayoutCacheKeys)가 접미사 없는 키만 지우면 + * activate/deactivate/uninstall/refresh 후에도 편집기용 병합 캐시가 잔존한다. + * + * @scenario cache_state=warm_stale,lifecycle_trigger=uninstall,template_type=admin + * + * @effects with_source_meta_layout_cache_variants_forgotten + */ + public function test_deactivate_template_clears_with_source_meta_layout_caches(): void + { + // Arrange + $this->templateManager->installTemplate('sirsoft-admin_basic'); + $this->templateManager->activateTemplate('sirsoft-admin_basic'); + + $template = Template::where('identifier', 'sirsoft-admin_basic')->first(); + $layouts = TemplateLayout::where('template_id', $template->id)->get(); + $this->assertGreaterThan(0, $layouts->count()); + + $warmedKeys = []; + foreach ($layouts as $layout) { + $key = "template.{$template->id}.layout.{$layout->name}.with_source_meta"; + $this->coreCache()->put($key, ['test' => 'meta'], 3600); + $warmedKeys[] = $key; + + // 소스 해시 변종 (LayoutService::getMergedLayoutCacheKey 와 동형 — 접미사는 해시 뒤) + if ($layout->source_type && $layout->source_identifier) { + $sourceHash = md5($layout->source_type->value.$layout->source_identifier); + $hashKey = "template.{$template->id}.layout.{$layout->name}.{$sourceHash}.with_source_meta"; + $this->coreCache()->put($hashKey, ['test' => 'meta'], 3600); + $warmedKeys[] = $hashKey; + } + } + + // Act + $this->templateManager->deactivateTemplate('sirsoft-admin_basic'); + + // Assert + foreach ($warmedKeys as $key) { + $this->assertNull( + $this->coreCache()->get($key), + "비활성화 후 편집기용 병합 캐시({$key})가 삭제되어야 합니다." + ); + } + } + /** * 테스트용 레이아웃 JSON 파일 생성 */ diff --git a/tests/Feature/Template/TemplateLanguageServingTest.php b/tests/Feature/Template/TemplateLanguageServingTest.php index 81f43f58..ec5f8478 100644 --- a/tests/Feature/Template/TemplateLanguageServingTest.php +++ b/tests/Feature/Template/TemplateLanguageServingTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Template; +use App\Contracts\Extension\CacheInterface; use App\Enums\ExtensionStatus; use App\Models\Template; use App\Services\TemplateService; @@ -129,9 +130,12 @@ class TemplateLanguageServingTest extends TestCase */ public function test_language_file_is_cached(): void { - // PublicBaseController::cached() 는 CacheInterface + v{version} 접미사 (기본 v=0) - $cache = app(\App\Contracts\Extension\CacheInterface::class); - $cacheKey = 'template.language.sirsoft-admin_basic.ko.v0'; + // `?v` 생략 시 현재 확장 캐시 버전으로 폴백하므로 (#588 — `.v0` 사각 키 방지) + // 버전을 시드해 결정적 키로 검증한다 + Cache::put('g7:core:ext.cache_version', 1234); + + $cache = app(CacheInterface::class); + $cacheKey = 'template.language.sirsoft-admin_basic.ko.v1234'; $cache->forget($cacheKey); // 첫 번째 요청 @@ -179,4 +183,4 @@ class TemplateLanguageServingTest extends TestCase $response = $this->getJson('/api/templates/sirsoft-admin_basic/lang/KO.json'); $response->assertStatus(404); } -} \ No newline at end of file +} diff --git a/tests/Unit/Cache/Migration/ArtisanCacheClearTest.php b/tests/Unit/Cache/Migration/ArtisanCacheClearTest.php index 116b9f00..b951ad44 100644 --- a/tests/Unit/Cache/Migration/ArtisanCacheClearTest.php +++ b/tests/Unit/Cache/Migration/ArtisanCacheClearTest.php @@ -16,7 +16,11 @@ use Tests\TestCase; * 새 CacheInterface 를 통해 캐시를 삭제하는지 검증. * * 실제 명령어 실행은 모듈 manager / plugin manager 의존성이 크므로, - * 명령어가 사용하는 키 형식과 forget 동작을 키 차원에서 검증합니다. + * CacheInterface 의 다건 put/forget 왕복 동작을 키 차원에서 검증합니다. + * + * 주의: 아래 키 문자열은 이관 당시의 픽스처다. 현행 커맨드는 유령 키 forget 을 + * 제거하고 상태 키 무효화 + 레이아웃 캐시 + 버전 bump 로 동작한다 (#588) — + * 커맨드의 실동작 검증은 {Module,Plugin,Template}ArtisanCommandsTest 가 담당한다. */ class ArtisanCacheClearTest extends TestCase { @@ -38,9 +42,7 @@ class ArtisanCacheClearTest extends TestCase } /** - * G-1-1: module:cache-clear 명령어 — 모듈 관련 캐시 삭제 검증. - * - * ClearModuleCacheCommand::clearModuleCache() 가 사용하는 키 패턴을 검증. + * G-1-1: 모듈 키 픽스처 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출). */ #[Test] public function g_1_1_module_cache_clear_removes_per_module_keys(): void @@ -58,7 +60,7 @@ class ArtisanCacheClearTest extends TestCase $this->cache->put($k, 'data'); } - // 명령어 핸들러 시뮬레이션 — CacheInterface.forget 호출 + // CacheInterface.forget 다건 호출 (드라이버 동작 검증) foreach ($keys as $k) { $this->cache->forget($k); } @@ -69,7 +71,7 @@ class ArtisanCacheClearTest extends TestCase } /** - * G-1-2: plugin:cache-clear 명령어 — 플러그인 캐시 삭제. + * G-1-2: 플러그인 키 픽스처 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출). */ #[Test] public function g_1_2_plugin_cache_clear_removes_per_plugin_keys(): void @@ -96,7 +98,7 @@ class ArtisanCacheClearTest extends TestCase } /** - * G-1-3: template:cache-clear 명령어 — 템플릿 + 레이아웃 + routes + 다국어 캐시 삭제. + * G-1-3: 템플릿/레이아웃 키 픽스처 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출). */ #[Test] public function g_1_3_template_cache_clear_removes_template_layout_routes(): void @@ -127,7 +129,7 @@ class ArtisanCacheClearTest extends TestCase } /** - * G-1-4: 인수 없이 호출 시 모든 모듈 캐시 + 전역 키 삭제. + * G-1-4: 전역 + 개별 키 혼합 다건 put→forget 왕복 (드라이버 동작 검증 — 커맨드 미호출). */ #[Test] public function g_1_4_clear_without_args_clears_all(): void @@ -143,7 +145,7 @@ class ArtisanCacheClearTest extends TestCase $this->cache->put("module.info.{$id}", 'data'); } - // 명령어 시뮬레이션 — 전역 + 개별 모두 삭제 + // 전역 + 개별 모두 forget (드라이버 동작 검증) $globalKeys = ['modules.all', 'modules.active', 'modules.installed']; foreach ($globalKeys as $k) { $this->cache->forget($k); diff --git a/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php b/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php new file mode 100644 index 00000000..0c16b976 --- /dev/null +++ b/tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php @@ -0,0 +1,86 @@ +methodSource( + base_path('app/Extension/TemplateManager.php'), + 'updateTemplate' + ); + + $this->assertStringContainsString( + '$this->clearTemplateCache(', + $body, + 'updateTemplate() 성공 cleanup 이 clearTemplateCache() 를 호출해야 합니다 — ' + .'누락 시 공개 config.json 이 캐시 TTL 동안 이전 manifest 로 응답됩니다.' + ); + } + + /** + * 대상 메서드 본문을 주석 제거 후 반환합니다. + * + * 주석을 남겨 두면 설명 문장에 들어 있는 호출 표기가 검사를 통과시킨다. + * + * @param string $path 대상 파일 절대 경로 + * @param string $method 대상 메서드명 + * @return string 주석이 제거된 메서드 본문 + */ + private function methodSource(string $path, string $method): string + { + $this->assertFileExists($path); + + $stripped = ''; + foreach (token_get_all(file_get_contents($path)) as $token) { + if (is_array($token) && in_array($token[0], [T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + $stripped .= is_array($token) ? $token[1] : $token; + } + + $start = strpos($stripped, "public function {$method}("); + $this->assertNotFalse($start, "{$path} 에서 {$method}() 메서드를 찾지 못했습니다."); + + // 중괄호 균형으로 메서드 본문 끝을 찾는다 + $open = strpos($stripped, '{', $start); + $depth = 0; + $length = strlen($stripped); + + for ($i = $open; $i < $length; $i++) { + if ($stripped[$i] === '{') { + $depth++; + } elseif ($stripped[$i] === '}') { + $depth--; + if ($depth === 0) { + return substr($stripped, $open, $i - $open + 1); + } + } + } + + $this->fail("{$path} 의 {$method}() 본문 끝을 찾지 못했습니다."); + } +} diff --git a/tests/scenarios/template-public-config-cache-invalidation.yaml b/tests/scenarios/template-public-config-cache-invalidation.yaml new file mode 100644 index 00000000..cfa96e72 --- /dev/null +++ b/tests/scenarios/template-public-config-cache-invalidation.yaml @@ -0,0 +1,33 @@ +# audit:allow test-scenario-coverage reason: axes 30 cross product(pairwise) 중 동작 분기가 실재하는 조합만 docblock 매핑. cache_state=cold 는 라이프사이클 트리거와 직교(캐시가 없으면 forget 은 무조건 no-op 멱등)라 트리거 조합 확장이 형식적이고, template_type 은 무효화 경로가 타입 무관 단일 코드(clearTemplateCache)라 admin 대표 + 구조 테스트의 user 로 충분. effects 7건은 전수 매핑됨. + +feature: template_public_config_cache_invalidation + +description: | + 공개 템플릿 설정(config.json) 서빙 캐시 `template.config.{identifier}` 는 버전 + 접미사 없는 고정 키라 캐시 버전 bump 로 무효화되지 않는다. 템플릿 라이프사이클 + (cache-clear 커맨드 단일/전체, update, deactivate, uninstall) 전 경로가 + `TemplateManager::clearTemplateCache()` 단일 지점을 경유해 이 키(+ components_manifest + 두 변종, 현재 버전 routes/language 키, `.with_source_meta` 편집기 변종)를 능동 + 삭제함을 고정한다 (#588, 공개 #119). + +coverage_strategy: pairwise + +axes: + cache_state: [cold, warm_current, warm_stale] + lifecycle_trigger: [cache_clear_command_single, cache_clear_command_all, template_update, deactivate, uninstall] + template_type: [admin, user] + +effects: + - cache_clear_command_forgets_template_config_key + - template_update_invalidates_public_config_cache + - deactivate_uninstall_invalidate_public_config_cache + - serve_config_caches_manifest_and_embeds_cache_version + - serve_config_returns_installed_manifest_after_invalidation + - clear_template_cache_forgets_components_manifest_variants + - with_source_meta_layout_cache_variants_forgotten + +test_files: + - tests/Feature/Template/TemplateCacheManagementTest.php + - tests/Feature/Template/PublicTemplateControllerTest.php + - tests/Feature/Console/Commands/Template/TemplateArtisanCommandsTest.php + - tests/Unit/Extension/TemplateUpdateConfigCacheInvalidationTest.php