From 5ba7a83597f40cb07e890395e24181280feed7dd Mon Sep 17 00:00:00 2001
From: HeuJung
-
+
diff --git a/README.md b/README.md
index 2cb73c03..55ed9323 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/app/Console/Commands/CleanupExtensionStaticCacheCommand.php b/app/Console/Commands/CleanupExtensionStaticCacheCommand.php
new file mode 100644
index 00000000..f5f10546
--- /dev/null
+++ b/app/Console/Commands/CleanupExtensionStaticCacheCommand.php
@@ -0,0 +1,42 @@
+cleanup();
+
+ $this->info("오래된 정적 게시 디렉토리 {$deleted}건이 삭제되었습니다.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/PublishExtensionStaticCacheCommand.php b/app/Console/Commands/PublishExtensionStaticCacheCommand.php
new file mode 100644
index 00000000..687ac28e
--- /dev/null
+++ b/app/Console/Commands/PublishExtensionStaticCacheCommand.php
@@ -0,0 +1,53 @@
+isEnabled()) {
+ $this->warn('정적 게시가 비활성화되어 있습니다 (G7_STATIC_CACHE=false). 게시를 건너뜁니다.');
+
+ return self::SUCCESS;
+ }
+
+ $published = $service->publishCurrent(force: (bool) $this->option('force'));
+
+ if (! $published) {
+ $this->error('정적 게시에 실패했습니다. 로그를 확인하세요 — 사이트는 API 폴백으로 정상 동작합니다.');
+
+ return self::FAILURE;
+ }
+
+ $this->info('부트스트랩 리소스 정적 게시가 완료되었습니다.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Exceptions/StaticCachePublishException.php b/app/Exceptions/StaticCachePublishException.php
new file mode 100644
index 00000000..6a3c3e38
--- /dev/null
+++ b/app/Exceptions/StaticCachePublishException.php
@@ -0,0 +1,12 @@
+ $e->getMessage(),
]);
}
+
+ // 부트스트랩 리소스 정적 게시(bake) 예약 — 모든 bump 호출부(수명주기 전체)가
+ // 이 단일 지점을 경유하므로 재게시 트리거 누락이 구조적으로 불가능하다 (#122).
+ // terminating 시점에 프로세스당 1회, 실행 시점의 최종 버전으로 게시된다
+ // (연속 bump 자연 병합). 실패해도 사이트는 API 폴백으로 정상.
+ ExtensionStaticCacheService::schedulePublishOnTerminate();
}
/**
diff --git a/app/Http/Controllers/Api/Base/BaseApiController.php b/app/Http/Controllers/Api/Base/BaseApiController.php
index 3b489b6f..c98b95fd 100644
--- a/app/Http/Controllers/Api/Base/BaseApiController.php
+++ b/app/Http/Controllers/Api/Base/BaseApiController.php
@@ -210,17 +210,37 @@ abstract class BaseApiController extends Controller
}
/**
- * JSON 응답을 반환합니다 (캐싱 헤더 포함).
+ * JSON 응답을 반환합니다 (조건부 캐싱 헤더 포함).
+ *
+ * ETag 기반 조건부 캐시(If-None-Match → 304)와 환경별 Cache-Control 분기를
+ * 적용한다 — 프로덕션은 `public, max-age`, 그 외 환경은 `no-cache`(파일 수정
+ * 즉시 반영 — `fileResponse` 의 환경 분기와 동일 사상).
*
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
- * @return JsonResponse JSON 응답
+ * @return JsonResponse|Response JSON 응답 또는 304 응답
*/
- protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
+ protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse|Response
{
+ $etag = $this->generateETag($data);
+
+ $cacheControl = app()->environment('production')
+ ? "public, max-age={$maxAge}"
+ : 'no-cache';
+
+ if ($status === 200 && $this->isNotModified($etag)) {
+ $response = $this->notModifiedResponse($etag, $maxAge);
+ $response->headers->set('Cache-Control', $cacheControl);
+ $response->headers->set('Vary', 'Accept-Encoding');
+
+ return $response;
+ }
+
return response()->json($data, $status, [
- 'Cache-Control' => "public, max-age={$maxAge}",
+ 'Cache-Control' => $cacheControl,
+ 'ETag' => $etag,
+ 'Vary' => 'Accept-Encoding',
], ResponseHelper::JSON_ENCODE_OPTIONS);
}
@@ -283,9 +303,18 @@ abstract class BaseApiController extends Controller
): JsonResponse|Response {
$etag = $this->generateETag($data);
+ // 환경별 캐싱 정책 — 프로덕션 외 환경은 no-cache 로 파일/데이터 수정 즉시 반영
+ // (`fileResponse`/`cachedJsonResponse` 와 동일 사상, #122 작업 D)
+ $cacheControl = app()->environment('production')
+ ? "public, max-age={$maxAge}"
+ : 'no-cache';
+
// 304 Not Modified 처리
if ($this->isNotModified($etag)) {
- return $this->notModifiedResponse($etag, $maxAge);
+ $response = $this->notModifiedResponse($etag, $maxAge);
+ $response->headers->set('Cache-Control', $cacheControl);
+
+ return $response;
}
return response()->json([
@@ -294,7 +323,7 @@ abstract class BaseApiController extends Controller
'data' => $data,
], 200, [], ResponseHelper::JSON_ENCODE_OPTIONS)
->header('ETag', $etag)
- ->header('Cache-Control', "public, max-age={$maxAge}")
+ ->header('Cache-Control', $cacheControl)
->header('Vary', 'Accept-Encoding, Accept-Language');
}
}
diff --git a/app/Http/Controllers/Api/Public/PublicModuleController.php b/app/Http/Controllers/Api/Public/PublicModuleController.php
index ffecc46e..1604afec 100644
--- a/app/Http/Controllers/Api/Public/PublicModuleController.php
+++ b/app/Http/Controllers/Api/Public/PublicModuleController.php
@@ -140,9 +140,9 @@ class PublicModuleController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 모듈 식별자
- * @return JsonResponse 컴포넌트 정의 응답
+ * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
- public function serveComponents(string $identifier): JsonResponse
+ public function serveComponents(string $identifier): JsonResponse|Response
{
$this->logApiUsage('modules.components', ['identifier' => $identifier]);
diff --git a/app/Http/Controllers/Api/Public/PublicPluginController.php b/app/Http/Controllers/Api/Public/PublicPluginController.php
index b062b2a6..2305cfe2 100644
--- a/app/Http/Controllers/Api/Public/PublicPluginController.php
+++ b/app/Http/Controllers/Api/Public/PublicPluginController.php
@@ -139,9 +139,9 @@ class PublicPluginController extends PublicBaseController
* 폴백한다(무손실 보존 디그레이드).
*
* @param string $identifier 플러그인 식별자
- * @return JsonResponse 컴포넌트 정의 응답
+ * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
- public function serveComponents(string $identifier): JsonResponse
+ public function serveComponents(string $identifier): JsonResponse|Response
{
$this->logApiUsage('plugins.components', ['identifier' => $identifier]);
diff --git a/app/Http/Controllers/Api/Public/PublicTemplateController.php b/app/Http/Controllers/Api/Public/PublicTemplateController.php
index b51cbd3b..5639762a 100644
--- a/app/Http/Controllers/Api/Public/PublicTemplateController.php
+++ b/app/Http/Controllers/Api/Public/PublicTemplateController.php
@@ -35,9 +35,9 @@ class PublicTemplateController extends PublicBaseController
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
*
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
- * @return JsonResponse 라우트 정보 응답
+ * @return JsonResponse|Response 라우트 정보 응답 (`?v` 명시 + If-None-Match 일치 시 304)
*/
- public function getRoutes(string $identifier): JsonResponse
+ public function getRoutes(string $identifier): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
@@ -101,6 +101,15 @@ class PublicTemplateController extends PublicBaseController
return $this->error(__('templates.errors.invalid_cache_data'), 500);
}
+ // 버전 키드 URL(`?v` 명시)은 bump 시 URL 자체가 바뀌므로 조건부 공개 캐시가 안전하다.
+ // 무버전 요청(핸드셰이크 폴백 등)은 종전대로 캐시 헤더 없이 신선 응답 (#122 작업 D).
+ // 열화 스냅샷은 공개 캐시 금지 — 서버측 캐시 회피(#493)와 동일 규율로, 같은 `?v`
+ // URL 에 public max-age 가 붙으면 브라우저/CDN 이 열화 응답을 1시간 박제한다
+ // (버전은 이미 올라간 뒤라 스스로 회복되지 않음. 정적 게시의 열화 제외와 대칭).
+ if ($rawVersion !== null && ! $this->templateService->lastRouteMergeWasDegraded()) {
+ return $this->successWithCache('templates.messages.routes_retrieved', $routesData['data'], 3600);
+ }
+
return $this->success(
__('templates.messages.routes_retrieved'),
$routesData['data']
@@ -145,9 +154,9 @@ class PublicTemplateController extends PublicBaseController
* 컴포넌트 정의 파일 서빙
*
* @param string $identifier 템플릿 식별자
- * @return JsonResponse 컴포넌트 정의 응답
+ * @return JsonResponse|Response 컴포넌트 정의 응답 (If-None-Match 일치 시 304)
*/
- public function serveComponents(string $identifier): JsonResponse
+ public function serveComponents(string $identifier): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.components', ['identifier' => $identifier]);
@@ -244,9 +253,9 @@ class PublicTemplateController extends PublicBaseController
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
- * @return JsonResponse 다국어 데이터 응답
+ * @return JsonResponse|Response 다국어 데이터 응답 (If-None-Match 일치 시 304)
*/
- public function serveLanguage(string $identifier, string $locale): JsonResponse
+ public function serveLanguage(string $identifier, string $locale): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage('templates.language', [
diff --git a/app/Seo/ComponentHtmlMapper.php b/app/Seo/ComponentHtmlMapper.php
index 07a237d5..39a1054a 100644
--- a/app/Seo/ComponentHtmlMapper.php
+++ b/app/Seo/ComponentHtmlMapper.php
@@ -1457,6 +1457,9 @@ class ComponentHtmlMapper
// 표현식 해석: 문자열/숫자는 evaluate, 배열/객체는 evaluateRaw
$resolved = $evaluator->evaluateRaw($value, $context);
$data[$key] = $resolved;
+ } elseif ($this->isSwitchDefinition($value)) {
+ // $switch 선언적 분기 (React resolveObject 와 동일 위치에서 해석)
+ $data[$key] = $this->resolveSwitchValue($value, $context, $evaluator);
} elseif (is_array($value)) {
// 중첩 객체 (예: socialLinks): 재귀적으로 해석
$data[$key] = $this->resolveAllPropsRecursive($value, $context, $evaluator);
@@ -1482,6 +1485,8 @@ class ComponentHtmlMapper
foreach ($values as $k => $v) {
if (is_string($v) && str_contains($v, '{{')) {
$result[$k] = $evaluator->evaluateRaw($v, $context);
+ } elseif ($this->isSwitchDefinition($v)) {
+ $result[$k] = $this->resolveSwitchValue($v, $context, $evaluator);
} elseif (is_array($v)) {
$result[$k] = $this->resolveAllPropsRecursive($v, $context, $evaluator);
} else {
@@ -1492,6 +1497,71 @@ class ComponentHtmlMapper
return $result;
}
+ /**
+ * 값이 `$switch` 선언적 분기 객체인지 판정합니다.
+ *
+ * React `DataBindingEngine.isSwitchExpression` 과 동일 — `$switch` 와 `$cases`
+ * 키를 모두 가진 객체(연관 배열)만 해당한다.
+ *
+ * @param mixed $value 판정 대상 값
+ * @return bool $switch 정의 여부
+ */
+ private function isSwitchDefinition(mixed $value): bool
+ {
+ return is_array($value)
+ && array_key_exists('$switch', $value)
+ && array_key_exists('$cases', $value);
+ }
+
+ /**
+ * `$switch` 선언적 분기 객체를 해석합니다.
+ *
+ * React `DataBindingEngine.resolveSwitch` 와 동일 의미론 (engine-v1.56.0 패리티):
+ * ① `$switch` 키 표현식을 평가해 문자열 키로 정규화(trim, 실패 시 빈 문자열)
+ * ② `$cases` 에서 키 일치 값 선택, 없으면 `$default`, 그것도 없으면 null(React undefined)
+ * ③ 결과가 `{{}}` 포함 문자열이면 재해석, 객체면 재귀 해석(중첩 $switch 포함)
+ *
+ * 레이아웃 최상위 `computed` 의 $switch 는 `SeoRenderer::resolveComputedSwitch` 가
+ * 별도 처리한다 — 이 메서드는 노드 props 값 축 담당.
+ *
+ * @param array $definition $switch 정의 { "$switch", "$cases", "$default"? }
+ * @param array $context 데이터 컨텍스트
+ * @param ExpressionEvaluator $evaluator 표현식 평가기
+ * @return mixed 해석된 값 (매칭·기본값 모두 없으면 null)
+ */
+ private function resolveSwitchValue(array $definition, array $context, ExpressionEvaluator $evaluator): mixed
+ {
+ try {
+ $keyValue = trim((string) $evaluator->evaluate((string) ($definition['$switch'] ?? ''), $context));
+ } catch (\Throwable) {
+ $keyValue = '';
+ }
+
+ $cases = is_array($definition['$cases'] ?? null) ? $definition['$cases'] : [];
+
+ if ($keyValue !== '' && array_key_exists($keyValue, $cases)) {
+ $result = $cases[$keyValue];
+ } elseif (array_key_exists('$default', $definition)) {
+ $result = $definition['$default'];
+ } else {
+ return null;
+ }
+
+ if (is_string($result)) {
+ return str_contains($result, '{{') ? $evaluator->evaluate($result, $context) : $result;
+ }
+
+ if ($this->isSwitchDefinition($result)) {
+ return $this->resolveSwitchValue($result, $context, $evaluator);
+ }
+
+ if (is_array($result)) {
+ return $this->resolveAllPropsRecursive($result, $context, $evaluator);
+ }
+
+ return $result;
+ }
+
/**
* {field|alt_field} 패턴에서 아이템 값을 해석합니다.
*
@@ -1688,6 +1758,12 @@ class ComponentHtmlMapper
continue;
}
+ // $switch 선언적 분기 객체 (engine-v1.56.0 React 패리티) — 해석하지 않으면
+ // 배열이라는 이유로 속성이 조용히 사라진다 (예외·경고 없음)
+ if ($this->isSwitchDefinition($value)) {
+ $value = $this->resolveSwitchValue($value, $context, $evaluator);
+ }
+
if (is_string($value)) {
$evaluated = $evaluator->evaluate($value, $context);
if ($evaluated !== '') {
diff --git a/app/Seo/SeoRenderer.php b/app/Seo/SeoRenderer.php
index 22c17fd3..1cf9fc91 100644
--- a/app/Seo/SeoRenderer.php
+++ b/app/Seo/SeoRenderer.php
@@ -730,7 +730,12 @@ class SeoRenderer implements SeoRendererInterface
foreach ($cssPaths as $cssPath) {
// dist/ 접두사 제거 (서빙 경로에서는 dist가 자동 추가됨)
$servePath = preg_replace('#^dist/#', '', $cssPath);
- $urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath);
+
+ // 정적 게시본(bake) 경로 금지 — 이 URL 은 SeoCacheManager(`seo.page.*`,
+ // 키에 cache_version 미포함)에 캐시된 HTML 에 박제되는데, 정적 디렉토리는
+ // GC 가 현재+직전 1개만 보존해 캐시 수명 안에 404 가 될 수 있다. SEO HTML 은
+ // asset-url-recovery 파샬도 없어 자가 복구가 불가하므로 무버전 API URL 고정.
+ $urls[] = AssetUrl::templateAsset($templateIdentifier, $servePath, allowStatic: false);
}
return $urls;
@@ -954,6 +959,11 @@ class SeoRenderer implements SeoRendererInterface
* 프론트엔드 TemplateApp이 레이아웃 레벨 initLocal/initGlobal을 상태에 적용하는 것과
* 동일하게, 각 값의 {{}} 표현식을 해석해 반환합니다.
*
+ * **데이터소스 레벨 `initLocal` 옵션은 의도적으로 처리하지 않는다** (2026-08-25 확정) —
+ * 그 옵션을 쓰는 화면(장바구니·주문서·프로필 수정·게시판 작성 폼 등)은 인증·인터랙션
+ * 화면이라 봇 렌더 가치가 없다. 봇 노출이 필요한 상태 시드는 레이아웃 최상위
+ * `initLocal`/`state` 를 사용한다 (docs/backend/seo-system.md 지원 노드 키 표 참조).
+ *
* @param mixed $block 초기 상태 블록 (키 → 값)
* @param array $context 현재 컨텍스트 (route, query 등 포함)
* @return array 평가된 초기 상태
diff --git a/app/Services/ExtensionStaticCacheService.php b/app/Services/ExtensionStaticCacheService.php
new file mode 100644
index 00000000..b906cb31
--- /dev/null
+++ b/app/Services/ExtensionStaticCacheService.php
@@ -0,0 +1,603 @@
+ 존재 여부) */
+ private array $publishedMemo = [];
+
+ public function __construct(
+ private TemplateService $templateService,
+ private TemplateRepositoryInterface $templateRepository,
+ private ExtensionBundleService $bundleService,
+ private LanguagePackService $languagePackService,
+ ) {}
+
+ /**
+ * 현재 확장 캐시 버전 기준으로 게시합니다.
+ *
+ * 이미 게시 완료(manifest 존재) 상태면 skip(멱등). `Cache::lock` 으로 단일
+ * 실행을 보장하며, 락 미획득 시 다른 프로세스가 게시 중인 것으로 보고 skip.
+ *
+ * @param bool $force 게시 완료 상태여도 강제 재게시
+ * @return bool 게시 완료 상태로 끝났으면 true (skip 포함), 실패/비활성이면 false
+ */
+ public function publishCurrent(bool $force = false): bool
+ {
+ if (! $this->isEnabled()) {
+ return false;
+ }
+
+ $version = self::getExtensionCacheVersion();
+
+ if (! $force && $this->isPublished($version)) {
+ return true;
+ }
+
+ $lock = Cache::lock(self::LOCK_PREFIX.$version, 300);
+
+ if (! $lock->get()) {
+ return false;
+ }
+
+ try {
+ // 락 대기 중 다른 프로세스가 완료했을 수 있다 (멱등 재확인)
+ unset($this->publishedMemo[$version]);
+ if (! $force && $this->isPublished($version)) {
+ return true;
+ }
+
+ return $this->publishVersion($version);
+ } finally {
+ $lock->release();
+ }
+ }
+
+ /**
+ * 해당 버전이 게시 완료 상태인지 확인합니다 (manifest 존재 = 완료).
+ *
+ * AssetUrl 게이트가 요청당 여러 번 호출하므로 메모이즈한다.
+ *
+ * @param int $version 확장 캐시 버전
+ * @return bool 게시 완료 여부
+ */
+ public function isPublished(int $version): bool
+ {
+ return $this->publishedMemo[$version] ??= is_file(
+ $this->versionDir($version).DIRECTORY_SEPARATOR.self::MANIFEST_FILE
+ );
+ }
+
+ /**
+ * 현재 버전 + 직전 1개를 보존하고 나머지 게시 디렉토리를 삭제합니다.
+ *
+ * 직전 버전을 남기는 이유: 브라우저에 캐시된 직전 렌더 HTML 이 아직 구버전
+ * 정적 URL 을 참조할 수 있다 (asset-url-recovery 파샬이 최후 방어).
+ *
+ * @return int 삭제된 디렉토리 수
+ */
+ public function cleanup(): int
+ {
+ $base = $this->baseDir();
+
+ if (! File::isDirectory($base)) {
+ return 0;
+ }
+
+ $current = self::getExtensionCacheVersion();
+ $versions = [];
+ $deleted = 0;
+
+ foreach (File::directories($base) as $dir) {
+ $name = basename($dir);
+
+ // 미완료 tmp 잔존물은 무조건 제거 대상 (rename 전 실패 흔적)
+ if (str_ends_with($name, '.tmp')) {
+ File::deleteDirectory($dir);
+ $deleted++;
+
+ continue;
+ }
+
+ if (ctype_digit($name)) {
+ $versions[(int) $name] = $dir;
+ }
+ }
+
+ // 현재 버전과, 현재를 제외한 최신 1개(직전) 보존
+ $keep = [$current];
+ $others = array_keys($versions);
+ rsort($others);
+ foreach ($others as $v) {
+ if ($v !== $current) {
+ $keep[] = $v;
+ break;
+ }
+ }
+
+ foreach ($versions as $v => $dir) {
+ if (! in_array($v, $keep, true)) {
+ File::deleteDirectory($dir);
+ $deleted++;
+ }
+ }
+
+ return $deleted;
+ }
+
+ /**
+ * 수명주기 이벤트에서 호출되는 terminating 게시 예약.
+ *
+ * `incrementExtensionCacheVersion()` 내부 단일 지점에서 호출된다.
+ * 프로세스당 1회만 등록하며, 게시는 예약 시점이 아니라 **실행 시점의 현재
+ * 버전**으로 수행되어 연속 bump(일괄 업데이트)를 자연 병합한다.
+ *
+ * 프로덕션 전용 — 비프로덕션은 blade 가 정적 URL 을 방출하지 않으므로
+ * 게시 자체가 무의미하고(§2-2), testing 환경의 파일 쓰기 부수효과도 차단한다.
+ */
+ public static function schedulePublishOnTerminate(): void
+ {
+ if (self::$publishScheduled || ! app()->environment('production')) {
+ return;
+ }
+
+ self::$publishScheduled = true;
+
+ app()->terminating(static function (): void {
+ // 실행 시점에 재무장 가능 상태로 복귀 — 요청마다 앱 인스턴스를 새로 쓰는
+ // 장수 프로세스(Octane 류)에서는 static 플래그만 살아남으므로, 리셋 없이는
+ // 2번째 이후 bump 가 새 앱에 콜백을 등록하지 못한 채 영구 미게시가 된다
+ // (자가 치유도 같은 플래그 공유). FPM(요청=프로세스)에서는 무영향.
+ self::$publishScheduled = false;
+
+ try {
+ app(self::class)->publishCurrent();
+ } catch (\Throwable $e) {
+ Log::warning('정적 게시 terminating 실행 실패 — 다음 렌더의 자가 치유가 재시도합니다', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ });
+ }
+
+ /**
+ * 테스트 격리용 — terminating 예약 플래그를 초기화합니다.
+ */
+ public static function resetPublishScheduleForTesting(): void
+ {
+ self::$publishScheduled = false;
+ }
+
+ /**
+ * 게시 루트 디렉토리 절대 경로를 반환합니다.
+ *
+ * @return string `public/build/ext` 절대 경로
+ */
+ public function baseDir(): string
+ {
+ return public_path('build/ext');
+ }
+
+ /**
+ * 버전 디렉토리 절대 경로를 반환합니다.
+ *
+ * @param int $version 확장 캐시 버전
+ * @return string 버전 디렉토리 절대 경로
+ */
+ public function versionDir(int $version): string
+ {
+ return $this->baseDir().DIRECTORY_SEPARATOR.$version;
+ }
+
+ /**
+ * kill-switch 판정 (`core.static_cache.enabled`, .env `G7_STATIC_CACHE`).
+ *
+ * @return bool 정적 게시 활성 여부
+ */
+ public function isEnabled(): bool
+ {
+ return (bool) config('core.static_cache.enabled', true);
+ }
+
+ /**
+ * 한 버전의 게시를 실제 수행합니다 (tmp 쓰기 → rename → manifest → GC).
+ *
+ * @param int $version 게시할 확장 캐시 버전
+ * @return bool 성공 여부
+ */
+ private function publishVersion(int $version): bool
+ {
+ $base = $this->baseDir();
+ $tmp = $base.DIRECTORY_SEPARATOR.$version.'.tmp';
+ $final = $this->versionDir($version);
+
+ try {
+ File::deleteDirectory($tmp);
+ File::ensureDirectoryExists($tmp, 0775);
+
+ $files = [];
+
+ $this->writeHtaccess($tmp, $files);
+
+ $locales = $this->publishableLocales();
+
+ foreach ($this->templateRepository->getActive() as $template) {
+ $this->publishTemplate($tmp, $template, $locales, $files);
+ }
+
+ $this->publishBundles($tmp, $version, $files);
+
+ // 원자적 스왑 — force 재게시 시 기존 디렉토리를 비켜낸 뒤 rename
+ if (File::isDirectory($final)) {
+ File::deleteDirectory($final);
+ }
+
+ if (! @rename($tmp, $final)) {
+ throw new StaticCachePublishException("Failed to rename publish directory: {$tmp} -> {$final}");
+ }
+
+ // manifest 는 rename 후 마지막 기록 — 존재 = 게시 완료
+ $this->writeManifest($final, $version, $files);
+ unset($this->publishedMemo[$version]);
+
+ // sudo/root CLI 게시 대응 — terminating 게시는 코어 업데이트의
+ // restoreOwnership **이후**(프로세스 종료 시)에 실행되므로, root 소유로
+ // 남으면 이후 php-fpm 의 재게시·GC 가 영구 실패한다. 부모(public/build)
+ // 소유권을 상속시킨다 (FilePermissionHelper::copyFile 의 sudo 대응 선례).
+ $this->normalizeOwnership();
+
+ // 인라인 GC (현재 + 직전 1개 보존)
+ $this->cleanup();
+
+ Log::info('부트스트랩 리소스 정적 게시 완료', [
+ 'version' => $version,
+ 'files' => count($files),
+ ]);
+
+ return true;
+ } catch (\Throwable $e) {
+ Log::warning('부트스트랩 리소스 정적 게시 실패 — API 폴백으로 동작합니다', [
+ 'version' => $version,
+ 'error' => $e->getMessage(),
+ ]);
+ File::deleteDirectory($tmp);
+
+ return false;
+ }
+ }
+
+ /**
+ * root 로 실행된 CLI 게시의 산출물 소유권을 부모 디렉토리 기준으로 정상화합니다.
+ *
+ * root 가 아닌 프로세스는 chown 자체가 불가능하고 필요도 없다(자기 소유로 생성됨)
+ * — 그 경우 즉시 no-op. 실패는 chownRecursive 가 경고 로그로 누적한다.
+ * 게시 루트 전체(`build/ext`)를 대상으로 하므로 방금 게시된 버전 디렉토리와
+ * 잔존 구버전이 함께 정상화된다.
+ */
+ private function normalizeOwnership(): void
+ {
+ if (! function_exists('chown') || ! function_exists('posix_geteuid') || posix_geteuid() !== 0) {
+ return;
+ }
+
+ $parent = dirname($this->baseDir());
+ $owner = @fileowner($parent);
+ $group = @filegroup($parent);
+
+ if ($owner === false || $owner === 0) {
+ return;
+ }
+
+ FilePermissionHelper::chownRecursive($this->baseDir(), $owner, $group);
+ }
+
+ /**
+ * 활성 템플릿 1개의 게시물(lang/components/routes/assets)을 기록합니다.
+ *
+ * @param string $tmp tmp 디렉토리 절대 경로
+ * @param Template $template 활성 템플릿
+ * @param array