> 서술자 목록 (로드 순서대로) */ private function collectCustomAssets(?string $templateIdentifier): array { if ($this->customAssetsDisabledByRequest()) { return []; } $assets = $this->resolveCustomAssets($templateIdentifier); // 변경을 감지해 버전이 올랐다면 방금 만든 URL 은 **옛 버전**을 가리킨다. // 그대로 내보내면 운영자가 파일을 고친 그 화면에서만 옛 CSS 가 보이고, 새로고침 // 한 번을 더 해야 반영된다 — 원인을 알 수 없는 한 박자 지연으로만 나타난다. // 새 버전으로 다시 해석한다. 게시본은 아직 없으므로 URL 은 API 형태로 떨어지고, // 그 응답은 디스크의 최신 내용을 그대로 준다. 다음 요청부터 정적 경로가 된다. if ($this->syncCustomAssetCacheVersion($templateIdentifier)) { CustomAssets::flushCache(); $assets = $this->resolveCustomAssets($templateIdentifier); } return $assets; } /** * 활성 확장의 사용자 추가 에셋 서술자를 해석합니다. * * @param string|null $templateIdentifier 활성 템플릿 식별자 * @return array> 서술자 목록 (로드 순서대로) */ private function resolveCustomAssets(?string $templateIdentifier): array { $assets = []; foreach ($this->activeExtensionIdentifiers('modules') as $identifier) { $assets = array_merge($assets, CustomAssets::forExtension('modules', $identifier)); } foreach ($this->activeExtensionIdentifiers('plugins') as $identifier) { $assets = array_merge($assets, CustomAssets::forExtension('plugins', $identifier)); } if (! empty($templateIdentifier)) { $assets = array_merge($assets, CustomAssets::forExtension('templates', $templateIdentifier)); } return $assets; } /** * 운영자가 파일을 고쳤으면 확장 캐시 버전을 올립니다. * * custom 자산은 확장 자산과 **같은 메커니즘**으로 정적 게시되므로 갱신 축도 같아야 * 한다. 그런데 확장 캐시 버전은 수명주기 이벤트에서만 오르고, 운영자가 FTP 로 파일 * 하나를 바꾸는 것은 그 이벤트가 아니다. 그래서 파일 서명이 달라진 것을 여기서 * 감지해 같은 단일 지점(`incrementExtensionCacheVersion`)을 호출한다 — 그 지점이 * 재게시 예약까지 담당하므로 새로 만들 기계가 없다. * * 서명이 **저장된 적 없으면 올리지 않는다.** 캐시 스토어가 요청마다 비는 환경 * (array 스토어 등)에서 "매번 달라짐" 으로 읽혀 버전이 무한히 오르고 재게시가 * 끝없이 돌게 되기 때문이다. 첫 관측은 기록만 하고 다음 변화부터 반응한다. * * 버전을 올렸으면 `true` 를 돌려준다 — 호출자가 서술자를 다시 해석해야 한다. * 이미 만든 URL 은 옛 버전을 가리키기 때문이다. * * 서명은 **렌더 스코프별로** 따로 기억한다. 수집 대상이 모듈·플러그인(양쪽 동일)에 * 더해 `resolveCustomAssets` 가 싣는 **그 렌더의 템플릿 하나**라, 관리자 렌더와 * 사용자 렌더의 서명은 파일이 그대로여도 정상적으로 다르다. 기억할 자리가 하나면 * 두 렌더가 번갈아 덮어쓰며 매번 "파일이 바뀌었다" 로 읽혀, 운영자가 아무것도 * 건드리지 않았는데 페이지를 오갈 때마다 확장 캐시 버전이 오르고 전체 재게시가 * 예약된다(모든 자산 URL 이 상시 변동 → 브라우저 캐시 무효). 예외도 화면 이상도 * 없어 로그 외에는 드러나지 않는다. * * 스코프를 나누되 **키는 하나로 두고 값을 맵으로** 쓴다. `CustomAssetService` 가 * 쓰기 뒤 이 키 하나를 `forget` 하는 것에 의존하므로(같은 키를 보는 두 소비자), * 키를 쪼개면 그 소비자가 일부만 지우게 되어 같은 결함군이 재생산된다. * * 서명 재료는 **게시와 같은 열거자**(`CustomAssets::publishableFiles`) — 활성 모듈·플러그인과 * 이 렌더의 템플릿의 `custom/**` 전체(하위 디렉토리·글꼴·이미지 포함)의 경로·수정 시각· * 크기다. 종전에는 로드 목록(최상위 css/js)의 mtime 만 봤는데, 게시는 재귀 복사라 * 글꼴만 교체한 변경이 영영 감지되지 않았다(#651 F7). 크기를 함께 넣는 이유는 mtime 해상도 * (1초·일부 파일시스템 2초) 안의 재작성과 mtime 보존 복사(rsync -t)를 잡기 위해서다. * * 스코프 키에는 **호스트명**을 붙인다. 다중 웹서버가 캐시를 공유하면 같은 파일이라도 서버마다 * 배포 시각(mtime)이 달라, 하나의 자리를 서버들이 번갈아 덮어쓰며 요청마다 "변경" 으로 * 읽혀 전체 재게시가 왕복한다(#651 F9). 서버별로 기억하면 각 서버는 자기 파일만 대조한다. * * @param string|null $scope 렌더 스코프 (활성 템플릿 식별자) * @return bool 확장 캐시 버전을 올렸으면 true */ private function syncCustomAssetCacheVersion(?string $scope = null): bool { try { $signature = $this->customAssetSignature($scope); $scopeKey = ($scope ?? '').'@'.(gethostname() ?: 'host'); // 버전 키와 같은 고정 스토어·코어 네임스페이스 — 컨테이너 바인딩을 거치면 재바인딩 // 컨텍스트에서 다른 네임스페이스에 저장되어 첫 관측 규칙이 실제 변경을 삼킨다. $cache = self::customSignatureCache(); // 구 배포본이 남긴 스칼라 값은 맵이 아니다 — 어느 스코프의 것인지 알 수 없으므로 // 서명으로 쓰지 않고 미관측으로 취급한다(첫 관측은 기록만 하니 헛된 bump 가 없다). $storedMap = $cache->get(CustomAssets::SIGNATURE_CACHE_KEY); $storedMap = is_array($storedMap) ? $storedMap : []; $stored = $storedMap[$scopeKey] ?? null; if ($stored === $signature) { return false; } $storedMap[$scopeKey] = $signature; // 버전 키와 같은 영구 TTL — 기본 TTL 로 만료되면 그 경계의 첫 관측이 "기록만" 이라 // 실제 변경 1회를 놓친다. $cache->put(CustomAssets::SIGNATURE_CACHE_KEY, $storedMap, ExtensionStaticCacheService::PERSISTENT_TTL_SECONDS); if ($stored === null) { return false; } Log::info('사용자 추가 에셋 변경 감지 — 확장 캐시 버전을 올립니다.', [ 'scope' => $scopeKey, 'previous' => $stored, 'current' => $signature, ]); $this->incrementExtensionCacheVersion(); return true; } catch (\Exception $e) { // 감지 실패가 화면을 막지 않는다 — 최악이라도 URL 이 한동안 옛 버전일 뿐이다 Log::warning('사용자 추가 에셋 변경 감지 실패', ['error' => $e->getMessage()]); return false; } } /** * 이 렌더 스코프의 custom 파일 서명을 만듭니다. * * 재료 = 활성 모듈 → 활성 플러그인 → 렌더 템플릿 순으로 `publishableFiles()` 전체의 * `{type}/{id}/{relative}@{mtime}@{size}`. 정렬 뒤 md5. 파일이 하나도 없으면 `empty`. * * @param string|null $scope 렌더 스코프 (활성 템플릿 식별자) * @return string 서명 */ private function customAssetSignature(?string $scope): string { $parts = []; $targets = []; foreach ($this->activeExtensionIdentifiers('modules') as $identifier) { $targets[] = ['modules', $identifier]; } foreach ($this->activeExtensionIdentifiers('plugins') as $identifier) { $targets[] = ['plugins', $identifier]; } if (! empty($scope)) { $targets[] = ['templates', $scope]; } foreach ($targets as [$type, $identifier]) { foreach (CustomAssets::publishableFiles($type, $identifier) as $file) { $parts[] = $type.'/'.$identifier.'/'.$file['relative'].'@'.$file['mtime'].'@'.$file['size']; } } sort($parts, SORT_STRING); return $parts === [] ? 'empty' : md5(implode('|', $parts)); } /** * 이번 요청이 사용자 추가 에셋을 끄도록 요구했는지 판정합니다. * * 판정을 수집 **맨 앞**에 두는 것이 중요하다. 뒤에 두면 빈 목록이 변경 감지 * (`syncCustomAssetCacheVersion`)에 도달해 "전부 사라짐" 으로 읽히고, 그 다음 정상 * 요청이 다시 "전부 생김" 으로 읽혀 요청마다 확장 캐시 버전이 오르내린다. * * @return bool `?custom=off` 이면 true */ private function customAssetsDisabledByRequest(): bool { try { return request()->query('custom') === 'off'; } catch (\Exception $e) { // 요청 컨텍스트가 없는 렌더(콘솔·SEO 사전 렌더 등)는 끄지 않는다 return false; } } /** * 활성 확장 식별자 목록을 돌려줍니다. * * @param string $extensionType `modules` | `plugins` * @return array 식별자 목록 */ private function activeExtensionIdentifiers(string $extensionType): array { try { $active = $extensionType === 'modules' ? $this->moduleManager->getActiveModules() : $this->pluginManager->getActivePlugins(); return array_keys($active); } catch (\Exception $e) { Log::warning("Failed to collect active {$extensionType} for custom assets: ".$e->getMessage()); return []; } } }