diff --git a/CHANGELOG.md b/CHANGELOG.md index 17766879..9f58e30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ - 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다. - S3 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등) 연결 지원 — 드라이버 설정에 엔드포인트 URL 과 Path-style 주소 옵션이 추가되어, AWS 가 아닌 S3 호환 스토리지도 관리자 화면 설정만으로 연결할 수 있습니다. S3 리전도 목록 선택 대신 자유 입력으로 바뀌어 새로 생기는 리전이나 R2 의 `auto` 값을 그대로 쓸 수 있습니다. - 서버에서 동작할 수 없는 드라이버(필요한 라이브러리·PHP 확장이 없는 경우)를 저장하려 하면 사유와 함께 거부합니다 — 잘못된 드라이버 저장으로 사이트가 멈추는 사고를 저장 시점에 차단합니다. +- 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.) +- 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다. + +### Changed + +- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7). ### Fixed diff --git a/README.ko.md b/README.ko.md index 13c684a7..f6c0ea99 100644 --- a/README.ko.md +++ b/README.ko.md @@ -515,9 +515,9 @@ cp .env.example .env glitter-gim jordy-bitree laelbe + lyg-kaban bigmsg hwaryeon1234 - lyg-kaban koojunho abc101 yks118 diff --git a/README.md b/README.md index 9b042eed..fe1eea44 100644 --- a/README.md +++ b/README.md @@ -531,9 +531,9 @@ Thanks to everyone who reported an issue or suggested a feature that shipped — glitter-gim jordy-bitree laelbe + lyg-kaban bigmsg hwaryeon1234 - lyg-kaban koojunho abc101 yks118 diff --git a/app/Contracts/Extension/CacheableExtensionInterface.php b/app/Contracts/Extension/CacheableExtensionInterface.php index 82bae277..29f70d15 100644 --- a/app/Contracts/Extension/CacheableExtensionInterface.php +++ b/app/Contracts/Extension/CacheableExtensionInterface.php @@ -25,6 +25,17 @@ interface CacheableExtensionInterface */ public function getStorage(): StorageInterface; + /** + * 카테고리별 스토리지 드라이버를 반환합니다. + * + * getStorageDiskFor($category) 가 결정한 디스크의 인스턴스를 반환하며, + * 기본 구현은 getStorage() 와 동일한 디스크를 사용합니다. + * + * @param string $category 카테고리 (settings, attachments, images, cache, temp) + * @return StorageInterface 카테고리 디스크의 스토리지 인스턴스 + */ + public function getStorageFor(string $category): StorageInterface; + /** * 확장 도메인에 격리된 캐시 드라이버를 반환합니다. * diff --git a/app/Contracts/Extension/StorageInterface.php b/app/Contracts/Extension/StorageInterface.php index efc2a8d9..f1b30113 100644 --- a/app/Contracts/Extension/StorageInterface.php +++ b/app/Contracts/Extension/StorageInterface.php @@ -2,6 +2,8 @@ namespace App\Contracts\Extension; +use Symfony\Component\HttpFoundation\StreamedResponse; + /** * 확장(모듈/플러그인) 스토리지 인터페이스 * @@ -50,12 +52,14 @@ interface StorageInterface /** * 파일의 공개 URL을 반환합니다. * - * public disk인 경우 직접 URL을 반환하고, - * private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용). + * public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면 + * 직접 URL 을 반환하고, 그 외에는 null 을 반환합니다 (별도 API 엔드포인트 사용). + * 생성 결과는 디스크 종류와 무관하게 `core.storage.filter_url` 필터 훅을 항상 통과하므로, + * 확장이 URL 을 공급/수정/차단할 수 있습니다 (null 반환도 훅 발화 대상). * * @param string $category 카테고리 * @param string $path 파일 경로 - * @return string|null 파일 URL (private disk인 경우 null) + * @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null) */ public function url(string $category, string $path): ?string; @@ -110,9 +114,9 @@ interface StorageInterface * @param string $path 파일 경로 * @param string $filename 다운로드 시 표시될 파일명 * @param array $headers 추가 HTTP 헤더 - * @return \Symfony\Component\HttpFoundation\StreamedResponse|null 파일 스트림 (파일이 없으면 null) + * @return StreamedResponse|null 파일 스트림 (파일이 없으면 null) */ - public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse; + public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse; /** * 사용할 디스크를 변경한 새 인스턴스를 반환합니다. @@ -132,7 +136,7 @@ interface StorageInterface * @param string $path 파일 경로 * @param string $filename 다운로드 시 표시될 파일명 * @param array $headers 추가 HTTP 헤더 - * @return \Symfony\Component\HttpFoundation\StreamedResponse|null 다운로드 응답 (파일이 없으면 null) + * @return StreamedResponse|null 다운로드 응답 (파일이 없으면 null) */ - public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse; + public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse; } diff --git a/app/Extension/AbstractExtensionServiceProvider.php b/app/Extension/AbstractExtensionServiceProvider.php index 8a41edfb..2b9023f9 100644 --- a/app/Extension/AbstractExtensionServiceProvider.php +++ b/app/Extension/AbstractExtensionServiceProvider.php @@ -40,6 +40,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider */ protected array $storageServices = []; + /** + * 카테고리별 StorageInterface 가 필요한 서비스 클래스 매핑 (클래스 ⇒ 카테고리). + * + * 등록된 클래스에는 getStorageFor($category) 인스턴스가 주입되어, 확장이 + * getStorageDiskFor() 를 오버라이드하면 해당 서비스의 put/getDisk() 가 + * 서비스 코드 무수정으로 카테고리 디스크를 따릅니다. + * + * @var array + */ + protected array $storageCategoryServices = []; + /** * CacheInterface 가 필요한 서비스 클래스 목록. * @@ -111,13 +122,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider */ protected function registerStorageBindings(): void { - if (empty($this->storageServices)) { - return; + if (! empty($this->storageServices)) { + $this->app->when($this->storageServices) + ->needs(StorageInterface::class) + ->give(fn () => $this->resolveExtension()->getStorage()); } - $this->app->when($this->storageServices) - ->needs(StorageInterface::class) - ->give(fn () => $this->resolveExtension()->getStorage()); + foreach ($this->storageCategoryServices as $service => $category) { + $this->app->when($service) + ->needs(StorageInterface::class) + ->give(fn () => $this->resolveExtension()->getStorageFor($category)); + } } /** diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php index 66fea241..a3c86b6a 100644 --- a/app/Extension/AbstractModule.php +++ b/app/Extension/AbstractModule.php @@ -1309,6 +1309,75 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte return 'modules'; } + /** + * 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize) + * + * @var array + */ + private array $storageByDisk = []; + + /** + * 카테고리별 스토리지 디스크 이름 반환 + * + * 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존). + * 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 모듈이 오버라이드합니다. + * + * 주의: 오버라이드 구현은 'settings' 카테고리에서 모듈 설정을 조회하면 안 됩니다 — + * 모듈 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다. + * 모듈 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다. + * + * @param string $category 카테고리 (settings, attachments, images, cache, temp) + * @return string 디스크 이름 + */ + public function getStorageDiskFor(string $category): string + { + return $this->getStorageDisk(); + } + + /** + * 카테고리별 스토리지 드라이버 인스턴스 반환 + * + * getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다. + * 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다. + * + * @param string $category 카테고리 + * @return StorageInterface 스토리지 드라이버 인스턴스 + */ + public function getStorageFor(string $category): StorageInterface + { + $disk = $this->getStorageDiskFor($category); + + if (! isset($this->storageByDisk[$disk])) { + $base = $this->getStorage(); + $this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk); + } + + return $this->storageByDisk[$disk]; + } + + /** + * 공개 자산 디스크 설정값을 해석합니다. + * + * 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk). + * 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로 + * 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다. + * + * @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용) + * @return string|null 사용할 디스크 이름 (스트리밍 유지면 null) + */ + protected function resolvePublicAssetDisk(?string $override = null): ?string + { + $disk = ($override !== null && $override !== '') + ? $override + : (string) config('core.storage.public_asset_disk', ''); + + if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) { + return null; + } + + return $disk; + } + /** * 모듈 캐시 드라이버 인스턴스 반환 * diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php index 1d41aa54..00b1749f 100644 --- a/app/Extension/AbstractPlugin.php +++ b/app/Extension/AbstractPlugin.php @@ -1203,6 +1203,75 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte return 'plugins'; } + /** + * 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize) + * + * @var array + */ + private array $storageByDisk = []; + + /** + * 카테고리별 스토리지 디스크 이름 반환 + * + * 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존). + * 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 플러그인이 오버라이드합니다. + * + * 주의: 오버라이드 구현은 'settings' 카테고리에서 플러그인 설정을 조회하면 안 됩니다 — + * 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다. + * 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다. + * + * @param string $category 카테고리 (settings, data, images, temp) + * @return string 디스크 이름 + */ + public function getStorageDiskFor(string $category): string + { + return $this->getStorageDisk(); + } + + /** + * 카테고리별 스토리지 드라이버 인스턴스 반환 + * + * getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다. + * 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다. + * + * @param string $category 카테고리 + * @return StorageInterface 스토리지 드라이버 인스턴스 + */ + public function getStorageFor(string $category): StorageInterface + { + $disk = $this->getStorageDiskFor($category); + + if (! isset($this->storageByDisk[$disk])) { + $base = $this->getStorage(); + $this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk); + } + + return $this->storageByDisk[$disk]; + } + + /** + * 공개 자산 디스크 설정값을 해석합니다. + * + * 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk). + * 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로 + * 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다. + * + * @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용) + * @return string|null 사용할 디스크 이름 (스트리밍 유지면 null) + */ + protected function resolvePublicAssetDisk(?string $override = null): ?string + { + $disk = ($override !== null && $override !== '') + ? $override + : (string) config('core.storage.public_asset_disk', ''); + + if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) { + return null; + } + + return $disk; + } + /** * 플러그인 캐시 드라이버 인스턴스 반환 * diff --git a/app/Extension/BaseModuleServiceProvider.php b/app/Extension/BaseModuleServiceProvider.php index ef70ba57..ef7b9325 100644 --- a/app/Extension/BaseModuleServiceProvider.php +++ b/app/Extension/BaseModuleServiceProvider.php @@ -139,4 +139,17 @@ final class InlineModuleExtensionAdapter implements CacheableExtensionInterface { return $this->storage ??= new ModuleStorageDriver($this->identifier); } + + /** + * 카테고리별 스토리지 드라이버를 반환합니다. + * + * fallback 어댑터는 모듈 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다. + * + * @param string $category 카테고리 + * @return StorageInterface 기본 디스크의 스토리지 인스턴스 + */ + public function getStorageFor(string $category): StorageInterface + { + return $this->getStorage(); + } } diff --git a/app/Extension/BasePluginServiceProvider.php b/app/Extension/BasePluginServiceProvider.php index f9efaae4..4c7bca0f 100644 --- a/app/Extension/BasePluginServiceProvider.php +++ b/app/Extension/BasePluginServiceProvider.php @@ -130,4 +130,17 @@ final class InlinePluginExtensionAdapter implements CacheableExtensionInterface { return $this->storage ??= new PluginStorageDriver($this->identifier); } + + /** + * 카테고리별 스토리지 드라이버를 반환합니다. + * + * fallback 어댑터는 플러그인 인스턴스(오버라이드 지점)가 없으므로 기본 스토리지를 그대로 반환합니다. + * + * @param string $category 카테고리 + * @return StorageInterface 기본 디스크의 스토리지 인스턴스 + */ + public function getStorageFor(string $category): StorageInterface + { + return $this->getStorage(); + } } diff --git a/app/Extension/Storage/Concerns/ResolvesPublicUrl.php b/app/Extension/Storage/Concerns/ResolvesPublicUrl.php new file mode 100644 index 00000000..1b125e2d --- /dev/null +++ b/app/Extension/Storage/Concerns/ResolvesPublicUrl.php @@ -0,0 +1,70 @@ +resolvePath($category, $path); + + $url = $this->diskSupportsDirectUrl() + ? Storage::disk($this->disk)->url($fullPath) + : null; + + $filtered = HookManager::applyFilters('core.storage.filter_url', $url, array_merge( + $this->urlHookContext(), + [ + 'disk' => $this->disk, + 'category' => $category, + 'path' => $path, + 'full_path' => $fullPath, + ] + )); + + return (is_string($filtered) && trim($filtered) !== '') ? $filtered : null; + } + + /** + * 현재 디스크가 직접 URL 생성을 지원하는지 판정합니다. + * + * public 디스크는 항상 지원, 그 외 디스크는 `filesystems.disks.{disk}.url` 이 + * 비어 있지 않은 문자열로 설정된 경우에만 지원합니다 (AWS_URL 미설정/빈값 방어). + * + * @return bool 직접 URL 지원 여부 + */ + private function diskSupportsDirectUrl(): bool + { + if ($this->disk === 'public') { + return true; + } + + $base = config("filesystems.disks.{$this->disk}.url"); + + return is_string($base) && trim($base) !== ''; + } + + /** + * `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다. + * + * @return array{scope: string, identifier: ?string} scope(core|module|plugin) + 확장 식별자(코어는 null) + */ + abstract protected function urlHookContext(): array; +} diff --git a/app/Extension/Storage/CoreStorageDriver.php b/app/Extension/Storage/CoreStorageDriver.php index 9e8a7fbd..402de7a7 100644 --- a/app/Extension/Storage/CoreStorageDriver.php +++ b/app/Extension/Storage/CoreStorageDriver.php @@ -3,7 +3,9 @@ namespace App\Extension\Storage; use App\Contracts\Extension\StorageInterface; +use App\Extension\Storage\Concerns\ResolvesPublicUrl; use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * 코어 스토리지 드라이버 @@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage; */ class CoreStorageDriver implements StorageInterface { + use ResolvesPublicUrl; + /** * 사용할 디스크 이름 */ @@ -93,17 +97,16 @@ class CoreStorageDriver implements StorageInterface } /** - * {@inheritDoc} + * `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다. + * + * @return array{scope: string, identifier: ?string} scope + 식별자 (코어는 null) */ - public function url(string $category, string $path): ?string + protected function urlHookContext(): array { - if ($this->disk !== 'public') { - return null; - } - - $fullPath = $this->resolvePath($category, $path); - - return Storage::disk($this->disk)->url($fullPath); + return [ + 'scope' => 'core', + 'identifier' => null, + ]; } /** @@ -155,7 +158,7 @@ class CoreStorageDriver implements StorageInterface /** * {@inheritDoc} */ - public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse + public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse { $fullPath = $this->resolvePath($category, $path); @@ -180,7 +183,7 @@ class CoreStorageDriver implements StorageInterface /** * {@inheritDoc} */ - public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse + public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse { $fullPath = $this->resolvePath($category, $path); diff --git a/app/Extension/Storage/ModuleStorageDriver.php b/app/Extension/Storage/ModuleStorageDriver.php index 17c80b82..e48f7805 100644 --- a/app/Extension/Storage/ModuleStorageDriver.php +++ b/app/Extension/Storage/ModuleStorageDriver.php @@ -3,7 +3,9 @@ namespace App\Extension\Storage; use App\Contracts\Extension\StorageInterface; +use App\Extension\Storage\Concerns\ResolvesPublicUrl; use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * 모듈 스토리지 드라이버 @@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage; */ class ModuleStorageDriver implements StorageInterface { + use ResolvesPublicUrl; + /** * 모듈 식별자 */ @@ -119,18 +123,16 @@ class ModuleStorageDriver implements StorageInterface } /** - * {@inheritDoc} + * `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다. + * + * @return array{scope: string, identifier: ?string} scope + 모듈 식별자 */ - public function url(string $category, string $path): ?string + protected function urlHookContext(): array { - // public disk인 경우에만 직접 URL 반환 - if ($this->disk !== 'public') { - return null; - } - - $fullPath = $this->resolvePath($category, $path); - - return Storage::disk($this->disk)->url($fullPath); + return [ + 'scope' => 'module', + 'identifier' => $this->identifier, + ]; } /** @@ -182,7 +184,7 @@ class ModuleStorageDriver implements StorageInterface /** * {@inheritDoc} */ - public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse + public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse { $fullPath = $this->resolvePath($category, $path); @@ -207,7 +209,7 @@ class ModuleStorageDriver implements StorageInterface /** * {@inheritDoc} */ - public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse + public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse { $fullPath = $this->resolvePath($category, $path); diff --git a/app/Extension/Storage/PluginStorageDriver.php b/app/Extension/Storage/PluginStorageDriver.php index 4a892b48..7d836a91 100644 --- a/app/Extension/Storage/PluginStorageDriver.php +++ b/app/Extension/Storage/PluginStorageDriver.php @@ -3,7 +3,9 @@ namespace App\Extension\Storage; use App\Contracts\Extension\StorageInterface; +use App\Extension\Storage\Concerns\ResolvesPublicUrl; use Illuminate\Support\Facades\Storage; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * 플러그인 스토리지 드라이버 @@ -13,6 +15,8 @@ use Illuminate\Support\Facades\Storage; */ class PluginStorageDriver implements StorageInterface { + use ResolvesPublicUrl; + /** * 플러그인 식별자 */ @@ -119,18 +123,16 @@ class PluginStorageDriver implements StorageInterface } /** - * {@inheritDoc} + * `core.storage.filter_url` 훅 컨텍스트의 드라이버별 식별 정보를 반환합니다. + * + * @return array{scope: string, identifier: ?string} scope + 플러그인 식별자 */ - public function url(string $category, string $path): ?string + protected function urlHookContext(): array { - // public disk인 경우에만 직접 URL 반환 - if ($this->disk !== 'public') { - return null; - } - - $fullPath = $this->resolvePath($category, $path); - - return Storage::disk($this->disk)->url($fullPath); + return [ + 'scope' => 'plugin', + 'identifier' => $this->identifier, + ]; } /** @@ -182,7 +184,7 @@ class PluginStorageDriver implements StorageInterface /** * {@inheritDoc} */ - public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse + public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse { $fullPath = $this->resolvePath($category, $path); @@ -207,7 +209,7 @@ class PluginStorageDriver implements StorageInterface /** * {@inheritDoc} */ - public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse + public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse { $fullPath = $this->resolvePath($category, $path); diff --git a/app/Http/Controllers/Api/Admin/PluginSettingsController.php b/app/Http/Controllers/Api/Admin/PluginSettingsController.php index 192a5335..a4b1a1e0 100644 --- a/app/Http/Controllers/Api/Admin/PluginSettingsController.php +++ b/app/Http/Controllers/Api/Admin/PluginSettingsController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\Admin; use App\Extension\PluginManager; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Admin\UpdatePluginSettingsRequest; +use App\Services\DriverRegistryService; use App\Services\PluginSettingsService; use App\Support\SensitiveSettingMask; use Illuminate\Http\JsonResponse; @@ -49,6 +50,34 @@ class PluginSettingsController extends AdminBaseController : SensitiveSettingMask::apply($settings, $plugin->getSettingsSchema()); } + /** + * 설정 스키마가 public_asset_disk 키를 선언한 플러그인의 응답에 + * 공개 자산 디스크 카탈로그(available_public_asset_disks)를 부착합니다. + * + * 화면이 /api/admin/settings(core.settings.read)를 따로 조회하게 두면 이 + * 화면의 권한(core.plugins.read)과 표면이 갈려, 커스텀 역할에서 카탈로그만 + * 조용히 비는 비대칭이 생깁니다. 설정 응답 단일 표면에서 함께 내려 권한 축을 + * 일치시킵니다 (ecommerce 모듈 설정 응답의 동명 키와 동형 계약). + * 저장 시에는 스키마 기반 검증 whitelist 가 이 키를 걸러 설정에 남지 않습니다. + * + * @param string $identifier 플러그인 식별자 + * @param array $settings 응답에 실을 설정 + * @return array 카탈로그가 부착된 설정 + */ + private function withPublicAssetCatalog(string $identifier, array $settings): array + { + $plugin = $this->pluginManager->getPlugin($identifier); + + if ($plugin === null || ! array_key_exists('public_asset_disk', $plugin->getSettingsSchema())) { + return $settings; + } + + $settings['available_public_asset_disks'] = app(DriverRegistryService::class) + ->getAvailableDrivers('public_asset'); + + return $settings; + } + /** * 플러그인 설정을 조회합니다. * @@ -63,7 +92,10 @@ class PluginSettingsController extends AdminBaseController return $this->notFound('plugins.not_found'); } - return $this->success('common.success', $this->maskSensitive($identifier, $settings)); + return $this->success( + 'common.success', + $this->withPublicAssetCatalog($identifier, $this->maskSensitive($identifier, $settings)) + ); } /** @@ -87,9 +119,14 @@ class PluginSettingsController extends AdminBaseController return $this->error('plugins.settings.update_failed', 500); } + // 저장 응답에도 카탈로그 재부착 — 화면 폼 상태가 응답으로 갱신되므로 + // 누락 시 저장 직후 선택지가 비어 보인다 (ecommerce 저장 응답과 동형) return $this->success( 'plugins.settings.updated', - $this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? []) + $this->withPublicAssetCatalog( + $identifier, + $this->maskSensitive($identifier, $this->pluginSettingsService->get($identifier) ?? []) + ) ); } diff --git a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php index 2b25a1e6..cbe8f2ec 100644 --- a/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php +++ b/app/Http/Requests/Admin/UpdatePluginSettingsRequest.php @@ -4,6 +4,7 @@ namespace App\Http\Requests\Admin; use App\Extension\HookManager; use App\Extension\PluginManager; +use App\Services\DriverRegistryService; use Illuminate\Foundation\Http\FormRequest; /** @@ -106,6 +107,24 @@ class UpdatePluginSettingsRequest extends FormRequest $rules[$field] = $fieldRules; } + // 공개 자산 디스크는 스키마 타입만으로 검증할 수 없다 — 선택지가 코어 3종 + + // 플러그인이 훅으로 등록한 디스크라 런타임에만 확정되기 때문이다. + // PluginSettingsController 가 카탈로그를 부착하는 게이트(스키마에 이 키를 선언한 + // 플러그인)와 동일한 조건에서 검증도 걸어, "선택지를 내려준 표면" 과 "값을 받는 + // 표면" 의 강도를 맞춘다. 이렇게 두면 이 키를 선언하는 플러그인은 각자 훅을 + // 구독하지 않아도 코어 환경설정과 같은 422 를 얻는다. + if (array_key_exists('public_asset_disk', $schema)) { + $rules['public_asset_disk'][] = function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === null || $value === '') { + return; + } + + if (! app(DriverRegistryService::class)->isDriverAvailable('public_asset', $value)) { + $fail(__('validation.settings.public_asset_disk_invalid')); + } + }; + } + // 표준 이름(`core.{대상}.{동작}_validation_rules`)으로 발행하되, 이미 공개돼 구독 중일 수 있는 // 구 이름(`core.plugin_settings.update_rules`)도 함께 발행한다 — 구 이름을 구독하는 제3자 // 확장이 조용히 멈추지 않도록 한다. 구 이름은 구독자가 있을 때만 1회 경고가 남는다. diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index 61af9831..706de27a 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -342,6 +342,9 @@ class SaveSettingsRequest extends FormRequest 'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]), 'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]), 'drivers.log_days' => ['nullable', 'integer', 'min:'.config('core.settings_limits.drivers_log_days_min', 1), 'max:'.config('core.settings_limits.drivers_log_days_max', 365)], + // 공개 자산 디스크 — 코어 3종(none/public/s3) + 플러그인 훅 등록 디스크. + // 카탈로그가 동적(플러그인 훅)이라 정적 Rule::in 불가 → 레지스트리 조회 closure + 'drivers.public_asset_disk' => $this->getPublicAssetDiskRules(), // 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨 'identity.default_provider' => ['nullable', 'string', 'max:100'], @@ -504,6 +507,32 @@ class SaveSettingsRequest extends FormRequest }; } + /** + * 공개 자산 디스크에 대한 검증 규칙을 반환합니다. + * + * 선택지가 코어 3종 + 플러그인 훅 등록 디스크로 동적이라 정적 Rule::in 을 쓸 수 없고, + * DriverRegistryService 카탈로그 조회로 판정합니다. + * + * @return array 검증 규칙 배열 + */ + private function getPublicAssetDiskRules(): array + { + return [ + 'nullable', + 'string', + 'max:100', + function ($attribute, $value, $fail) { + if ($value === null || $value === '') { + return; + } + + if (! app(DriverRegistryService::class)->isDriverAvailable('public_asset', $value)) { + $fail(__('validation.settings.public_asset_disk_invalid')); + } + }, + ]; + } + /** * 추가 검증을 위해 validator after 콜백을 등록합니다. * @@ -816,6 +845,8 @@ class SaveSettingsRequest extends FormRequest 'drivers.log_days.integer' => __('validation.settings.log_days_integer'), 'drivers.log_days.min' => __('validation.settings.log_days_min'), 'drivers.log_days.max' => __('validation.settings.log_days_max'), + 'drivers.public_asset_disk.string' => __('validation.settings.public_asset_disk_invalid'), + 'drivers.public_asset_disk.max' => __('validation.settings.public_asset_disk_invalid'), // 본인인증(IDV) 설정 'identity.default_provider.string' => __('validation.settings.identity_default_provider_string'), @@ -973,6 +1004,7 @@ class SaveSettingsRequest extends FormRequest 'drivers.log_driver' => __('validation.attributes.log_driver'), 'drivers.log_level' => __('validation.attributes.log_level'), 'drivers.log_days' => __('validation.attributes.log_days'), + 'drivers.public_asset_disk' => __('validation.attributes.public_asset_disk'), ]; } } diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php index 2c67c9ad..ac3a4378 100644 --- a/app/Models/Attachment.php +++ b/app/Models/Attachment.php @@ -111,6 +111,7 @@ class Attachment extends Model */ public function getFullPathAttribute(): string { + // audit:allow no-storage-disk-direct reason: 행 disk 의 파일시스템 절대경로 해석 — 코어 첨부 path 는 카테고리 없는 원 경로라 StorageInterface(카테고리/경로) 계약과 형태가 다르고, 모델 accessor 는 DI 지점이 없다 (기존 동작 유지) return Storage::disk($this->disk)->path($this->path); } @@ -121,7 +122,21 @@ class Attachment extends Model */ public function getDownloadUrlAttribute(): string { - return '/api/attachment/'.$this->hash; + return self::urlForHash($this->hash); + } + + /** + * 해시 기반 다운로드 URL 조립 단일 지점. + * + * 조인 결과 행처럼 모델 hydration 없이 hash 만 손에 있는 호출측 + * (예: 게시판 인기 게시물 목록의 아바타 URL)이 사용합니다. + * + * @param string $hash 첨부파일 해시 + * @return string 다운로드 URL + */ + public static function urlForHash(string $hash): string + { + return '/api/attachment/'.$hash; } /** diff --git a/app/Providers/SettingsServiceProvider.php b/app/Providers/SettingsServiceProvider.php index 88f6f1c5..f9b1b702 100644 --- a/app/Providers/SettingsServiceProvider.php +++ b/app/Providers/SettingsServiceProvider.php @@ -49,6 +49,7 @@ class SettingsServiceProvider extends ServiceProvider $this->applyAppConfig($configRepository); $this->applyDebugConfig($configRepository); $this->applyDriverConfig($configRepository); + $this->applyPublicAssetDiskConfig($configRepository); $this->applyUploadConfig($configRepository); $this->applyCoreUpdateConfig($configRepository); $this->applyGeoIpConfig($configRepository); @@ -364,6 +365,40 @@ class SettingsServiceProvider extends ServiceProvider $this->applyLogConfig($driverSettings); } + /** + * 공개 자산 디스크 설정을 적용합니다. + * + * testing 환경에서는 dev 공유 drivers.json 값이 테스트로 흘러들지 않도록 + * 주입을 건너뜁니다 (테스트 격리). 실제 주입/정규화는 + * injectPublicAssetDiskConfig() 가 담당합니다 — 가드와 분리해 두어야 + * 정규화 규칙('none' → '')이 테스트에서 단언 가능합니다. + */ + private function applyPublicAssetDiskConfig(JsonConfigRepository $configRepository): void + { + if (env('APP_ENV') === 'testing') { + return; + } + + $this->injectPublicAssetDiskConfig($configRepository); + } + + /** + * drivers.public_asset_disk 저장값을 core.storage.public_asset_disk 로 주입합니다. + * + * 'none'(스트리밍 유지 선택)/빈값은 미설정('')으로 정규화합니다. + * 테스트 격리 가드(applyPublicAssetDiskConfig)를 통과한 뒤에만 호출됩니다. + * + * @param JsonConfigRepository $configRepository 설정 저장소 + */ + private function injectPublicAssetDiskConfig(JsonConfigRepository $configRepository): void + { + $driverSettings = $configRepository->getCategory('drivers'); + + $disk = (string) ($driverSettings['public_asset_disk'] ?? ''); + + Config::set('core.storage.public_asset_disk', $disk === 'none' ? '' : $disk); + } + /** * Redis 연결 설정을 적용합니다. */ diff --git a/app/Services/DriverRegistryService.php b/app/Services/DriverRegistryService.php index 209b4fad..2320b3d9 100644 --- a/app/Services/DriverRegistryService.php +++ b/app/Services/DriverRegistryService.php @@ -27,6 +27,7 @@ class DriverRegistryService */ private const CORE_DRIVER_IDS = [ 'storage' => ['local', 's3'], + 'public_asset' => ['none', 'public', 's3'], 'cache' => ['file', 'redis'], 'session' => ['file', 'database', 'redis'], 'queue' => ['sync', 'database', 'redis'], @@ -42,6 +43,7 @@ class DriverRegistryService */ private const DEFAULT_DRIVERS = [ 'storage' => 'local', + 'public_asset' => 'none', 'cache' => 'file', 'session' => 'database', 'queue' => 'database', @@ -57,6 +59,7 @@ class DriverRegistryService */ public const CONFIG_KEYS = [ 'storage' => 'filesystems.default', + 'public_asset' => 'core.storage.public_asset_disk', 'cache' => 'cache.default', 'session' => 'session.driver', 'queue' => 'queue.default', @@ -75,6 +78,7 @@ class DriverRegistryService */ private const SETTINGS_KEYS = [ 'storage' => ['category' => 'drivers', 'key' => 'storage_driver'], + 'public_asset' => ['category' => 'drivers', 'key' => 'public_asset_disk'], 'cache' => ['category' => 'drivers', 'key' => 'cache_driver'], 'session' => ['category' => 'drivers', 'key' => 'session_driver'], 'queue' => ['category' => 'drivers', 'key' => 'queue_driver'], diff --git a/app/Support/ApiDoc/ParameterDescriber.php b/app/Support/ApiDoc/ParameterDescriber.php index 7986ff97..8ce26e7e 100644 --- a/app/Support/ApiDoc/ParameterDescriber.php +++ b/app/Support/ApiDoc/ParameterDescriber.php @@ -168,6 +168,7 @@ class ParameterDescriber 'port' => '포트 번호', 'encryption' => '전송 암호화 방식 (tls/ssl)', 'storage_driver' => '스토리지 드라이버 (local/s3)', + 'public_asset_disk' => '공개 자산 직접 URL 서빙 디스크 (none/public/s3 + 플러그인 등록 디스크)', 'cache_driver' => '캐시 드라이버 (file/redis/memcached)', 'session_driver' => '세션 드라이버 (file/database/redis)', 'queue_driver' => '큐 드라이버 (sync/database/redis)', diff --git a/config/core.php b/config/core.php index e132f002..0202098e 100644 --- a/config/core.php +++ b/config/core.php @@ -125,6 +125,19 @@ return [ 'like_operator_default' => 'like', ], + /* + |-------------------------------------------------------------------------- + | 스토리지 — 공개 자산 직접 URL 서빙 디스크 + |-------------------------------------------------------------------------- + | 완전 공개 자산(상품/카테고리/리뷰/에디터 이미지)의 직접 URL(CDN) 서빙에 쓸 + | 디스크입니다. 빈 문자열이면 미설정(기존 PHP 스트리밍 유지)이며, 값은 관리자 + | 환경설정(drivers.public_asset_disk)에서 SettingsServiceProvider 가 주입합니다. + | 확장은 개별 설정으로 이 전역값을 오버라이드할 수 있습니다. + */ + 'storage' => [ + 'public_asset_disk' => '', + ], + /* |-------------------------------------------------------------------------- | 코어 권한 정의 diff --git a/config/settings/defaults.json b/config/settings/defaults.json index 9a915a66..56757daa 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -118,6 +118,7 @@ }, "drivers": { "storage_driver": "local", + "public_asset_disk": "none", "s3_bucket": "", "s3_region": "ap-northeast-2", "s3_access_key": "", @@ -302,6 +303,7 @@ "_comment": "드라이버 설정 — 프론트엔드 미노출 (admin_settings에서 API 원본값 기반 비교로 변경)", "fields": { "storage_driver": { "type": "string", "sensitive": false }, + "public_asset_disk": { "type": "string", "sensitive": false }, "s3_bucket": { "type": "string", "sensitive": false }, "s3_region": { "type": "string", "sensitive": false }, "s3_access_key": { "type": "string", "sensitive": true }, diff --git a/docs/backend/admin-settings-access.md b/docs/backend/admin-settings-access.md index a11c9df9..f81e5c72 100644 --- a/docs/backend/admin-settings-access.md +++ b/docs/backend/admin-settings-access.md @@ -38,6 +38,7 @@ | `drivers.session_lifetime` | `session.lifetime` (testing 차단) | | `drivers.queue_driver` | `queue.default` (testing 차단) | | `drivers.storage_driver` | `filesystems.default` | +| `drivers.public_asset_disk` | `core.storage.public_asset_disk` (`'none'`/빈값 → `''`, testing 차단) | | `drivers.search_engine_driver` | `scout.driver` | | `drivers.redis_*` | `database.redis.*` | | `drivers.memcached_*` | `cache.stores.memcached.*` | diff --git a/docs/backend/api/plugins.md b/docs/backend/api/plugins.md index 6cc66aad..a84769ba 100644 --- a/docs/backend/api/plugins.md +++ b/docs/backend/api/plugins.md @@ -1581,6 +1581,8 @@ HTTP/1.1 200 **설명** 플러그인의 현재 설정 값을 조회합니다. `core.plugins.read` 권한이 필요합니다. 저장된 설정이 없거나 플러그인을 찾을 수 없으면 404 를 반환합니다. 설정 페이지 진입 시 폼의 현재 값을 채우는 용도이며, 폼 스키마/UI 구성은 별도의 `settings/layout` 엔드포인트에서 조회합니다. +설정 스키마(getSettingsSchema)에 `public_asset_disk` 키를 선언한 플러그인(예: `sirsoft-ckeditor5`)은 응답 `data` 에 공개 자산 디스크 카탈로그 `available_public_asset_disks` 가 함께 부착됩니다 — 각 항목은 `{id, label(로케일별 맵), provider?}` 구조입니다. 설정 화면이 코어 환경설정 API(`core.settings.read`)를 따로 조회하지 않고 이 응답 하나로 선택지를 구성하게 하기 위한 것으로, 이 키는 저장 대상이 아니며 PUT 시 검증 whitelist 에서 걸러집니다. + ### PUT /api/admin/plugins/{identifier}/settings @@ -1641,7 +1643,7 @@ _단건 응답: `data` 는 저장 후 다시 조회한 설정 값 맵입니다. -**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 저장 대상은 검증을 통과한 값뿐이며, 검증 규칙은 해당 플러그인이 선언한 설정 스키마에서 생성됩니다. 스키마에 없는 필드를 요청에 포함해도 저장되지 않습니다. PluginManager 에 등록되지 않은 플러그인은 저장 자체가 수행되지 않아 500 을 반환합니다. 성공 시 갱신된 설정 값을 함께 반환합니다. +**설명** 플러그인의 설정 값을 저장합니다. `core.plugins.update` 권한이 필요합니다. 저장 대상은 검증을 통과한 값뿐이며, 검증 규칙은 해당 플러그인이 선언한 설정 스키마에서 생성됩니다. 스키마에 없는 필드를 요청에 포함해도 저장되지 않습니다. PluginManager 에 등록되지 않은 플러그인은 저장 자체가 수행되지 않아 500 을 반환합니다. 성공 시 갱신된 설정 값을 함께 반환합니다. 설정 스키마에 `public_asset_disk` 키를 선언한 플러그인은 저장 응답에도 `available_public_asset_disks` 카탈로그가 재부착됩니다 (GET 응답과 동형 — 저장 직후 화면 폼 상태 갱신용). ### GET /api/admin/plugins/{identifier}/settings/layout @@ -2226,11 +2228,30 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — `?file=` 쿼리로 받은 경로의 에셋 +파일 원본 바이트를 그대로 서빙합니다 (성공 시 200 또는 304). 파일 경로 미전달 +(`file` 쿼리 부재)은 422 로 응답합니다._ + +| 응답 헤더 | 예시값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `text/javascript` | 파일 확장자에서 감지한 MIME 타입 | +| ETag | `"a1b2c3d4…"` | 파일 내용 기반 검증자 (재요청 시 304 판정) | +| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 no-cache) | **응답 예시** - +```http +GET /api/plugins/assets/sirsoft-daum_postcode?file=dist%2Fjs%2Fplugin.iife.js HTTP/1.1 +``` + +```http +HTTP/1.1 200 +Content-Type: text/javascript +Cache-Control: public, max-age=31536000 +ETag: "a1b2c3d4e5f6" + +(function(){/* 플러그인 IIFE 번들 원본 바이트 */})(); +``` **에러 응답** @@ -2241,7 +2262,7 @@ Accept: application/json -**설명** +**설명** 플러그인 에셋 서빙(`GET /api/plugins/assets/{identifier}/{path}`)의 확장자 없는 이중 모드 변형입니다. 파일 경로를 경로 세그먼트 대신 `?file=` 쿼리로 받으며, 검증·에러·캐시 동작은 경로 세그먼트 형태와 동일합니다. `.js`·`.css` 주소를 가로채는 정적 파일 최적화 서버 설정에서 확장자 붙은 주소가 404 가 될 때 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드). ### GET /api/plugins/assets/{identifier}/{path} @@ -2425,19 +2446,36 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 플러그인 CSS 를 병합한 +텍스트를 그대로 서빙합니다 (성공 시 200 또는 304). `GET /api/plugins/bundle.css` +와 동일 응답입니다._ + +| 응답 헤더 | 예시값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `text/css` | 항상 CSS | +| ETag | `"a1b2c3d4…"` | 병합 파일 내용 기반 검증자 (병합 파일이 있는 경우) | +| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 매 요청 재병합) | + +활성 global 플러그인 에셋이 하나도 없으면 본문이 빈 200 응답(`text/css`) 입니다. **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/css +Cache-Control: public, max-age=31536000 + +/* sirsoft-gdpr */ +.g7-gdpr-banner{position:fixed;bottom:0} +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._ -**설명** +**설명** 플러그인 CSS 번들(`GET /api/plugins/bundle.css`)의 확장자 없는 이중 모드 변형입니다. 동작·응답이 확장자 형태와 동일하며, `.css` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드). ### GET /api/plugins/bundle/js @@ -2460,19 +2498,37 @@ Accept: application/json **응답 필드** (`data` 내부) - +_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — 활성 플러그인 IIFE JS 를 병합한 +텍스트를 그대로 서빙합니다 (성공 시 200 또는 304). `GET /api/plugins/bundle.js` +와 동일 응답입니다._ + +| 응답 헤더 | 예시값 | 용도/설명 | +| --- | --- | --- | +| Content-Type | `text/javascript` | 항상 JavaScript | +| ETag | `"a1b2c3d4…"` | 병합 파일 내용 기반 검증자 (병합 파일이 있는 경우) | +| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 매 요청 재병합) | + +활성 global 플러그인 에셋이 하나도 없으면 본문이 빈 200 응답(`text/javascript`) 입니다. 병합 시 각 IIFE 사이는 `\n;\n` 구분자로 연결됩니다. **응답 예시** - +```http +HTTP/1.1 200 +Content-Type: text/javascript +Cache-Control: public, max-age=31536000 + +(function(){/* sirsoft-gdpr plugin.iife.js */})(); +; +(function(){/* sirsoft-ckeditor5 plugin.iife.js */})(); +``` **에러 응답** -_대표 에러 없음 (공개 조회). _ +_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._ -**설명** +**설명** 플러그인 JS 번들(`GET /api/plugins/bundle.js`)의 확장자 없는 이중 모드 변형입니다. 동작·응답이 확장자 형태와 동일하며, `.js` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드). ### GET /api/plugins/{identifier}/components @@ -2497,9 +2553,19 @@ Accept: application/json **응답 필드** (`data` 내부) +_이 엔드포인트는 `success/message/data` 봉투를 사용하지 않습니다 — 플러그인의 +`components.json` 원문을 그대로 JSON body 로 반환합니다 (`Cache-Control: public, +max-age=3600`). 파일이 없는 구버전 플러그인은 빈 객체(`{}`) 로 폴백합니다._ - - +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| $schema | string | `https://json-schema.org/draft/2020-12/schema` | 매니페스트 JSON 스키마 참조 | +| identifier | string | `sirsoft-daum_postcode` | 이 매니페스트를 소유한 플러그인 식별자 | +| version | string | `1.0.0` | 매니페스트(플러그인) 버전 | +| components | object | `{"basic":[],"composite":[],"layout":[]}` | 타입별 컴포넌트 정의 맵 | +| components.basic | array | `[]` | Basic 컴포넌트 정의 목록 (HTML 래핑 계층) | +| components.composite | array | `[]` | Composite 컴포넌트 정의 목록 (집합 컴포넌트) | +| components.layout | array | `[]` | Layout 컴포넌트 정의 목록 | **응답 예시** @@ -2528,7 +2594,7 @@ HTTP/1.1 200 -**설명** +**설명** 플러그인 컴포넌트 매니페스트(`GET /api/plugins/{identifier}/components.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드). ### GET /api/plugins/{identifier}/components.json diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index e5a8c409..c2f7a7b3 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -71,7 +71,7 @@ _단건 응답: `data` 객체의 필드._ | geoip | object | `{"geoip_enabled":false,"geoip_license_key":null,"geoip_au…` | GeoIP 원본 카테고리 (GeoIP 사용 여부·MaxMind 라이선스 키·DB 자동 갱신 사용). advanced 탭에 병합된 파생 뷰 | | notifications | object | `{"channels":[{"id":"mail","is_active":true,"sort_order":1…` | 알림 탭 설정 그룹. channels 는 알림 채널 목록으로 각 원소가 id(채널 식별자)·is_active(활성 여부)·sort_order(표시 순서)를 가짐 | | identity | object | `{"default_provider":"g7:core.mail","purpose_providers":{"…` | 본인인증(IDV) 탭 설정 그룹 (기본 provider·목적별 provider 매핑(purpose_providers)·챌린지 유효시간(분)·최대 시도 횟수) | -| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공 | +| available_drivers | object | `{"storage":[{"id":"local","label":{"ko":"로컬","en":"Local"…` | 드라이버 선택지 카탈로그 (DriverRegistryService 산물). 종류별(storage/public_asset/cache/session/queue 등) 선택 가능한 드라이버 목록을 id/다국어 label 형태로 제공. `public_asset` 은 공개 자산 직접 URL 서빙 디스크 선택지 (코어 none/public/s3 + 플러그인 훅 등록분) | | _meta | object | `{"limits":{"upload_max_file_size_min":1,"upload_max_file_…` | 화면 검증 메타 — `limits` 는 각 설정 항목의 min/max 경계값 맵 (`config/core.php` 의 `settings_limits` 가 SSoT, 화면 입력 힌트와 FormRequest 검증이 같은 값을 공유) | | abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | @@ -244,6 +244,7 @@ HTTP/1.1 200 | advanced.geoip_license_key | body | string | 아니오 | max 200 | MaxMind GeoLite2 라이선스 키 | | advanced.geoip_auto_update_enabled | body | boolean | 아니오 | — | GeoIP DB 자동 업데이트 사용 여부 (주 1회 자동 재다운로드) | | drivers.storage_driver | body | string | 아니오 | — | 스토리지 드라이버 (local/s3) | +| drivers.public_asset_disk | body | string | 아니오 | max 100 | 공개 자산 직접 URL 서빙 디스크 (none/public/s3 + 플러그인 훅 등록 디스크). 카탈로그(`available_drivers.public_asset`)에 없는 값은 422 | | drivers.s3_bucket | body | string | 아니오 | max 255 | S3 버킷명 | | drivers.s3_region | body | string | 아니오 | max 64, 소문자 영숫자·하이픈 (`^[a-z0-9-]+$`) | S3 리전 — AWS 리전 코드 또는 S3 호환 스토리지 값 (Cloudflare R2 는 `auto`, MinIO 관례는 `us-east-1`) | | drivers.s3_access_key | body | string | 아니오 | max 255 | S3 액세스 키 | diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index c2c5ba6b..b1e36603 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -121,6 +121,10 @@ core.attachment.download core.attachment.update core.attachment.delete +# 스토리지 공개 URL 훅 (Filter) — StorageInterface::url() 결과 공급/수정/차단 (공개#100) +# 컨텍스트 6키는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조 +core.storage.filter_url + # 업로드 트라이어드 — 사용자 첨부 업로드 지점의 표준 3훅 패턴 # before_upload(액션) → filter_upload_file(필터: UploadedFile 을 받아 변형본을 반환. # 저장 파일명·MIME·크기가 모두 반환 파일 기준이 된다) → after_upload(액션) @@ -162,7 +166,9 @@ core.layout_extension.before_apply core.layout_extension.after_apply # 드라이버 확장 훅 (Filter) — 플러그인이 새 드라이버를 등록 +# 항목 3키 구조는 아래 "스토리지·드라이버 확장 훅 페이로드" 표 참조 core.settings.available_storage_drivers +core.settings.available_public_asset_drivers # 공개 자산 직접 URL 서빙 디스크 (공개#100) core.settings.available_cache_drivers core.settings.available_session_drivers core.settings.available_queue_drivers @@ -195,6 +201,35 @@ core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'fai > `sitemap.index.collect_for_resource` 는 제3자 확장이 리소스 하나에 대한 sitemap 항목을 추가/보정할 때 씁니다(filter — `'type' => 'filter'` 명시 필수). `core.seo.sitemap.after_regenerate_failed` 는 재생성 잡이 실패했을 때 확장이 알림/복구를 걸 수 있는 action 훅입니다. +### 스토리지·드라이버 확장 훅 페이로드 + +#### `core.storage.filter_url` + +`StorageInterface::url()` 의 결과를 공급·수정·차단합니다. 첫 인자는 생성된 URL(`?string`)이며, **디스크 종류와 무관하게 항상 발화**합니다 — 직접 URL 을 만들 수 없어 `null` 인 경우에도 발화하므로 확장이 서명 URL 등을 공급할 수 있습니다. 반환이 문자열이 아니거나 빈 문자열/공백이면 호출측이 스트리밍으로 폴백합니다. + +두 번째 인자는 컨텍스트 배열입니다. + +| 키 | 타입 | 값 | +| --- | --- | --- | +| `scope` | string | `core` / `module` / `plugin` — 호출한 드라이버 종류 | +| `identifier` | ?string | 확장 식별자 (`sirsoft-ecommerce` 등). 코어 드라이버는 `null` | +| `disk` | string | 대상 디스크명 | +| `category` | string | 스토리지 카테고리 (`images`, `settings` 등) | +| `path` | string | 카테고리 하위 상대 경로 | +| `full_path` | string | 디스크 루트 기준 전체 경로 — `Storage::temporaryUrl()` 등에 그대로 사용 가능 | + +#### `core.settings.available_{category}_drivers` + +플러그인이 드라이버/디스크 선택지를 카탈로그에 추가합니다. 첫 인자인 드라이버 배열에 다음 구조의 항목을 append 합니다. + +| 키 | 타입 | 값 | +| --- | --- | --- | +| `id` | string | 드라이버/디스크 식별자 — 저장값이자 config 조회 키 | +| `label` | array | 로케일별 표시 라벨 (예: `['ko' => 'CDN', 'en' => 'CDN']`) | +| `provider` | string | 공급 플러그인 식별자 — 비활성화 시 "사용 중 드라이버" 경고 판정에 사용 (코어 기본 항목에는 없음) | + +`core.settings.available_public_asset_drivers` 로 등록하는 디스크는 플러그인 ServiceProvider 에서 `filesystems.disks.{id}` 정의가 함께 있어야 하며, 그 정의에 `url` 키가 있어야 직접 URL 이 생성됩니다(없으면 스트리밍 폴백). 플러그인이 비활성화되어 디스크 정의가 사라지면 저장값은 보존된 채 스트리밍으로 자동 폴백합니다. + --- ## 리스너 구현 diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md index 3a4139c5..dc2e8432 100644 --- a/docs/extension/storage-driver.md +++ b/docs/extension/storage-driver.md @@ -867,7 +867,17 @@ public function url(string $category, string $path): ?string - `$path` (string): 카테고리 하위 상대 경로 **반환값**: -- `string|null`: 파일 URL (private disk인 경우 null) +- `string|null`: 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null) + +**직접 URL 판정**: `public` 디스크이거나 `filesystems.disks.{disk}.url` 이 비어 있지 않은 +문자열로 설정된 디스크(S3+CDN 등)면 직접 URL 을 생성합니다. url 설정이 없거나 +빈 문자열/공백이면 null 입니다 (AWS_URL 미설정 방어). + +**필터 훅**: 생성 결과는 디스크 종류와 무관하게 `core.storage.filter_url` 필터 훅을 +**항상** 통과합니다 (null 도 발화 대상). 확장은 URL 을 공급(서명 URL 등)/수정(도메인 +교체)/차단(빈 문자열 반환 → 호출측 스트리밍 폴백)할 수 있습니다. 컨텍스트 6키: +`scope`(core|module|plugin) / `identifier`(확장 식별자, 코어는 null) / `disk` / +`category` / `path` / `full_path`. **예시**: ```php @@ -875,7 +885,11 @@ public function url(string $category, string $path): ?string $url = $this->storage->url('images', 'product/2024/01/19/uuid.jpg'); // → http://example.com/storage/modules/sirsoft-ecommerce/images/product/2024/01/19/uuid.jpg -// local (private) disk인 경우 +// url 설정 디스크(S3+CDN)인 경우 +$url = $this->storage->url('images', 'product/2024/01/19/uuid.jpg'); +// → https://cdn.example.com/modules/sirsoft-ecommerce/images/product/2024/01/19/uuid.jpg + +// url 미설정 디스크(local 등)인 경우 $url = $this->storage->url('attachments', 'notice/2024/01/19/file.pdf'); // → null (별도 API 엔드포인트 사용해야 함) ``` @@ -1233,13 +1247,18 @@ class BoardModule extends AbstractModule - `$this->storage->url()` 호출 시 항상 null이 반환됩니다. **원인**: -- private disk (local, s3 등)를 사용 중입니다. URL 메서드는 public disk에서만 직접 URL을 반환합니다. +- 사용 중인 디스크에 `filesystems.disks.{disk}.url` 설정이 없습니다. url() 은 + `public` 디스크이거나 url 이 설정된 디스크에서만 직접 URL 을 반환합니다. + s3 디스크라도 url(AWS_URL/관리자 S3 URL 설정)이 비어 있으면 null 입니다. **해결**: ```php -// public disk를 사용하거나 +// public disk 를 사용하거나 $module->getStorageDisk(); // → 'public' +// 디스크에 url 을 설정하거나 (config/filesystems.php 또는 관리자 S3 URL 설정) +'my_cdn' => ['driver' => 's3', ..., 'url' => 'https://cdn.example.com'], + // 또는 별도 API 엔드포인트를 사용 Route::get('/api/attachments/{id}/download', [AttachmentController::class, 'download']); ``` @@ -1334,11 +1353,18 @@ if (Storage::disk('local')->exists($oldPath)) { **끝!** Service 코드는 변경할 필요가 없습니다. +**직접 URL(CDN) 서빙까지 원한다면**: `filesystems.disks.s3.url`(관리자 환경설정의 +S3 URL — CDN 도메인)이 반드시 설정되어야 합니다. url 이 비어 있으면 `url()` 은 +null 을 반환해 스트리밍 경로가 유지됩니다. + --- ### Q3: 여러 disk를 동시에 사용할 수 있나요? -**A**: 네, 카테고리별로 다른 disk를 사용할 수 있습니다. +**A**: 네, `getStorageDiskFor(string $category)` 를 오버라이드하면 카테고리별로 다른 +disk 를 사용할 수 있습니다. `getStorageFor($category)` 가 그 결정에 따라 디스크 단위로 +memoize 된 인스턴스를 돌려주고, ServiceProvider 의 `$storageCategoryServices` 매핑이 +해당 서비스에 카테고리 디스크 스토리지를 자동 주입합니다. ```php exists($oldPath)) { namespace Modules\Sirsoft\Ecommerce; use App\Extension\AbstractModule; -use App\Contracts\Extension\StorageInterface; -use App\Extension\Storage\ModuleStorageDriver; -class EcommerceModule extends AbstractModule +class Module extends AbstractModule { /** - * 카테고리별 Storage 인스턴스 캐시 + * 카테고리별 디스크 결정 — 기본 구현은 getStorageDisk() 와 동일. + * + * 주의: 'settings' 카테고리에서 모듈 설정을 조회하면 설정 로드와 재귀 고리가 + * 생깁니다. 설정 조회는 설정 저장과 무관한 카테고리에서만 수행하세요. */ - private array $storageCache = []; - - /** - * 카테고리별로 다른 Storage를 반환합니다. - */ - public function getStorage(string $category = 'default'): StorageInterface + public function getStorageDiskFor(string $category): string { - if (isset($this->storageCache[$category])) { - return $this->storageCache[$category]; + if ($category !== 'images') { + return $this->getStorageDisk(); } - // 카테고리별 disk 설정 - $disk = match ($category) { - 'images' => config('sirsoft-ecommerce.image_disk', 's3'), - 'attachments' => config('sirsoft-ecommerce.attachment_disk', 'local'), - default => $this->getStorageDisk(), - }; + // 공개 자산 디스크 해석 (확장 개별 설정 > 코어 전역 > 미설정 → 기본 디스크) + $override = module_setting('sirsoft-ecommerce', 'basic_info.public_asset_disk', ''); - $this->storageCache[$category] = new ModuleStorageDriver($this->getIdentifier(), $disk); - - return $this->storageCache[$category]; + return $this->resolvePublicAssetDisk(is_string($override) ? $override : '') + ?? $this->getStorageDisk(); } } ``` +```php +// ServiceProvider — 카테고리 디스크가 필요한 서비스는 $storageCategoryServices 에 매핑 +protected array $storageCategoryServices = [ + ProductImageService::class => 'images', +]; +``` + --- ### Q4: 플러그인에서도 사용할 수 있나요? @@ -1525,6 +1549,62 @@ class AttachmentServiceTest extends TestCase --- +## 공개 자산 전용 디스크 분리 (직접 URL/CDN 서빙) + +완전 공개 자산(상품/카테고리/리뷰/에디터 이미지 등)을 S3+CDN 등 원격 디스크에서 +직접 URL 로 서빙하는 옵트인 기능입니다. 미설정 시 기존 PHP 스트리밍이 100% 보존됩니다. + +### 설정 사슬 + +```text +관리자 환경설정 > 드라이버 탭 > 공개 자산 스토리지 (drivers.public_asset_disk) + → SettingsServiceProvider 가 core.storage.public_asset_disk 로 주입 + → 확장의 getStorageDiskFor('images') 가 resolvePublicAssetDisk() 로 해석 + (우선순위: 확장 개별 설정 public_asset_disk > 코어 전역 > 미설정 → 기본 디스크) + → ServiceProvider $storageCategoryServices 매핑으로 업로드 서비스에 주입 + → put() 이 그 디스크에 저장, 행 disk 컬럼에 기록 + → 모델 download_url accessor 가 행 disk 로 url() 시도 → null 이면 API 경로 폴백 +``` + +- 선택지 카탈로그는 코어 3종(`none`/`public`/`s3`) + 플러그인이 + `core.settings.available_public_asset_drivers` 필터 훅으로 등록한 디스크입니다. + 플러그인은 자기 ServiceProvider 에서 `Config::set('filesystems.disks.{id}', [... 'url' => CDN])` + 로 디스크를 정의하고 훅에 `{id, label, provider}` 를 append 합니다. +- 확장 개별 오버라이드 화면의 카탈로그는 **그 확장의 설정 조회 응답에 부착**해 내립니다 + (`available_public_asset_disks` 키 — ecommerce 는 모듈 설정 컨트롤러가, 플러그인은 설정 + 스키마에 `public_asset_disk` 선언 시 코어 플러그인 설정 API 가 자동 부착). 화면이 코어 + 환경설정 API 를 교차 조회하면 화면 권한과 카탈로그 권한(core.settings.read)이 갈려 + 커스텀 역할에서 선택지만 조용히 비게 됩니다. +- `none` 은 스트리밍 유지(확장 개별 설정에서는 전역이 CDN 이어도 강제 스트리밍)입니다. +- 플러그인 비활성화로 디스크가 config 에서 사라지면(고아 디스크) 자동으로 스트리밍 + 폴백합니다 — 저장값은 보존되므로 재활성화 시 되살아납니다. + +### 혼재 운용 + +디스크 전환 시 기존 파일 이동이 필요 없습니다. 행마다 기록된 disk 를 기준으로 +서빙/삭제/이동이 동작하므로, 전환 이전 로컬 행은 스트리밍으로, 이후 원격 행은 +직접 URL 로 각자 올바르게 서빙됩니다. + +행에 기록된 disk 가 고아가 된 경우(그 디스크를 제공하던 플러그인을 비활성화한 뒤에도 +그 disk 로 기록된 행이 남아 있는 경우)에도 서빙·삭제는 예외 없이 동작합니다. 행 disk +기준 스토리지를 만들 때 디스크 설정 존재 여부를 먼저 확인하고, 없으면 확장의 기본 +디스크로 폴백하기 때문입니다. 파일 자체는 도달 불가이므로 그 이미지는 404 가 되지만, +목록·상세 화면과 삭제(상품/카테고리/리뷰 삭제 포함)는 정상 동작합니다. 이 검증을 +생략하면 미등록 디스크 접근이 예외가 되어 서빙과 삭제가 모두 500 이 됩니다. + +### 서명 URL 등 커스텀 URL 공급 + +`core.storage.filter_url` 필터 훅이 디스크 무관 항상 발화하므로, 확장이 서명 URL 을 +공급하거나 생성된 URL 을 수정/차단할 수 있습니다 (url() 절 참조). + +### 보안 전제 + +권한 검사가 걸린 첨부파일(비밀글/회원 전용 게시판 첨부 등)은 배선 자체가 없습니다 — +직접 URL 은 서버 스트리밍 경로의 권한 검사를 우회하므로, 완전 공개 자산 카테고리에만 +공개 자산 디스크를 적용합니다. + +--- + ## 관련 문서 - [모듈 개발 가이드](module-basics.md) diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index b301f960..5bdbb050 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -11,6 +11,7 @@ - S3 호환 스토리지 설정 신설 항목(엔드포인트 URL·Path-style 주소)과 리전 형식 안내의 검증 메시지 일본어 번역 추가 (`validation.settings.s3_endpoint_*`, `s3_use_path_style_boolean`, `s3_region_*`) — 드라이버 설정 저장 시 오류 안내가 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.) - 드라이버 사용 불능 안내 문구 일본어 번역 추가 (`validation.settings.driver_unusable`, `settings.driver_unusable_*`, `settings.s3_adapter_missing`) — 필요한 라이브러리·PHP 확장이 없는 드라이버를 저장·테스트할 때의 사유 안내가 일본어 로케일에서 표시됩니다. - 웹소켓 서버(백엔드 발송용) endpoint 연결 실패 안내 일본어 번역 추가 (`settings.websocket_server_test_failed`). +- 공개 자산 스토리지(공개 이미지 직접 URL 서빙) 설정의 드라이버 라벨과 검증 메시지 일본어 번역을 추가했습니다. 환경설정 > 드라이버 탭의 새 설정이 일본어 로케일에서 자연스럽게 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php index 3df98c0e..569cc50f 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php @@ -36,6 +36,11 @@ return [ 'local' => 'ローカル', 's3' => 'Amazon S3', ], + 'public_asset' => [ + 'none' => '使用しない (ストリーミング)', + 'public' => 'Public ディスク', + 's3' => 'Amazon S3', + ], 'cache' => [ 'file' => 'ファイル', 'redis' => 'Redis', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index 1f1595b3..7db50071 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -954,6 +954,7 @@ return [ 'log_days_integer' => 'ログ保持日数は整数である必要があります。', 'log_days_min' => 'ログ保持日数は1以上である必要があります。', 'log_days_max' => 'ログ保持日数は365を超えることはできません。', + 'public_asset_disk_invalid' => '正しい公開アセットディスクを選択してください。', 's3_bucket_required' => 'S3バケット名は必須です。', 's3_access_key_required' => 'S3 Access Keyは必須です。', 's3_secret_key_required' => 'S3 Secret Keyは必須です。', @@ -1157,6 +1158,7 @@ return [ 'log_driver' => 'ログ ドライバー', 'log_level' => 'ログレベル', 'log_days' => 'ログ保持日数', + 'public_asset_disk' => '公開アセットディスク', ], 'layout_version' => [ 'limit' => [ diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index a3cfeee3..deb02267 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -11,6 +11,8 @@ - 배송정책 구간 설정의 새 검증 안내(구간 미등록, 중간 구간 종료값 누락, 수량 구간의 정수 제한, 단위값·무료배송 기준금액 누락, 도서산간 우편번호 형식) 일본어 번역을 추가했습니다. - 배송정책 편집 화면에서 구간 입력 오류를 안내하는 문구(중간 구간 종료값 누락, 배송비 미입력, 수량 구간 정수 제한, 단위값·무료배송 기준금액 미입력) 일본어 번역을 추가했습니다. - 배송비 요약에 표시되는 구간 단위(개/kg/L)와 구간 입력란의 단위 라벨 일본어 번역을 추가했습니다. +- 이커머스 설정 기본 정보 탭의 "공개 자산 스토리지" 설정 문구 일본어 번역을 추가했습니다. +- 공개 자산 디스크에 사용할 수 없는 값을 저장할 때 나오는 오류 안내와 필드명의 일본어 번역을 추가했습니다. ### Changed diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 7a321ba6..ea6e8ef6 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -1208,7 +1208,13 @@ return [ 'min' => '注文金額は 0 以上である必要があります。', ], ], + // 公開アセットディスク (コアカタログ照会による検証) + 'public_asset_disk_invalid' => '有効な公開アセットディスクを選択してください。', + 'attributes' => [ + // 公開アセットストレージ + 'basic_info.public_asset_disk' => '公開アセットディスク', + 'basic_info' => '基本情報', 'basic_info.shop_name' => 'ショップ名', 'basic_info.route_path' => 'ルートパス', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json index d756c545..fc2a9be1 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json @@ -58,6 +58,13 @@ "telecom_number_placeholder": "付加通信事業者番号", "postcode_plugin_required": "Daum郵便番号プラグインがインストールされていないか無効化されています。管理者>プラグインで有効化してください。" }, + "public_asset_storage": { + "title": "公開アセットストレージ", + "description": "商品・カテゴリ・レビュー画像など、このモジュールの完全公開アセットの保存ディスクを設定します。", + "disk": "公開アセットディスク", + "follow_core": "コア設定に従う", + "help": "このモジュールだけ別のディスクを使う場合に選択します。空にするとコア環境設定の公開アセットストレージ設定に従います。" + }, "inquiry_settings": { "title": "お問い合わせ掲示板設定", "description": "商品お問い合わせ機能を設定します。", diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md index 6145af21..36adcbf8 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.1] - 2026-08-12 + +### Added + +- 에디터 설정의 "공개 자산 디스크" 설정 문구 일본어 번역을 추가했습니다. + ## [1.0.0] - 2026-07-01 ### Added diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json index f07e97cb..1705344e 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/frontend/ja.json @@ -29,6 +29,11 @@ "label": "最大画像サイズ (MB)", "hint": "アップロード可能な画像の最大サイズです。(1 〜 10 MB)" }, + "public_asset_disk": { + "label": "公開アセットディスク", + "hint": "このプラグインだけ別のディスクを使う場合に選択します。空にするとコアの公開アセットディスク設定に従います。", + "follow_core": "コア設定に従う" + }, "editor_height": { "label": "エディター高さ (px)", "hint": "エディターの最小高さをピクセル単位で設定します。(200 〜 2000 px)" diff --git a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json index 11e85335..be35b069 100644 --- a/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json +++ b/lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 plugin (sirsoft-ckeditor5) Japanese language pack (bundled)", "ja": "G7 プラグイン (sirsoft-ckeditor5) 日本語 言語パック(バンドル)" }, - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "scope": "plugin", "target_identifier": "sirsoft-ckeditor5", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 51d3a512..633ea5b3 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 환경설정 > 드라이버 > 파일 스토리지의 S3 신설 항목 일본어 번역 추가 (`settings.drivers.storage.s3_endpoint*`, `s3_use_path_style*`, `s3_region_desc`) — 엔드포인트 URL·Path-style 토글·리전 입력 도움말이 일본어 로케일에서 자연스럽게 표시됩니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.) +- 환경설정 드라이버 탭의 "공개 자산 스토리지" 카드 문구 일본어 번역을 추가했습니다. ### Changed diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index c8e50961..992ff0b6 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1641,6 +1641,12 @@ "s3_url": "S3 URL (公開URL)", "s3_url_desc": "ファイルの公開URL生成に使うCDN/カスタムドメインです。APIリクエストアドレスはエンドポイントURLに入力してください。" }, + "public_asset": { + "title": "公開アセットストレージ", + "desc": "完全公開アセット(商品・カテゴリ・レビュー・エディタ画像など)を直接URLで配信するディスクを設定します。使用しない場合は既存のストリーミング方式で動作します。", + "disk": "ディスク", + "s3_help": "S3を選択する場合は、上のファイルストレージカードのS3 URL(CDNドメイン)設定が必要です。未設定の場合はストリーミングで動作します。" + }, "cache": { "title": "キャッシュ", "desc": "アプリケーションキャッシュドライバーを設定します。", diff --git a/lang/en/settings.php b/lang/en/settings.php index be45cee4..a26dc289 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -43,6 +43,11 @@ return [ 'local' => 'Local', 's3' => 'Amazon S3', ], + 'public_asset' => [ + 'none' => 'Disabled (streaming)', + 'public' => 'Public disk', + 's3' => 'Amazon S3', + ], 'cache' => [ 'file' => 'File', 'redis' => 'Redis', diff --git a/lang/en/validation.php b/lang/en/validation.php index 9a992bdb..8695403c 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -1088,6 +1088,9 @@ return [ 'log_days_min' => 'Log retention days must be at least 1.', 'log_days_max' => 'Log retention days may not be greater than 365.', + // Public asset disk settings + 'public_asset_disk_invalid' => 'Please select a valid public asset disk.', + // Identity verification (IDV) settings 'identity_default_provider_string' => 'Default provider must be a string.', 'identity_default_provider_max' => 'Default provider identifier may not be longer than 100 characters.', @@ -1300,5 +1303,6 @@ return [ 'log_driver' => 'log driver', 'log_level' => 'log level', 'log_days' => 'log retention days', + 'public_asset_disk' => 'public asset disk', ], ]; diff --git a/lang/ko/settings.php b/lang/ko/settings.php index e71a61cd..36e0fbef 100644 --- a/lang/ko/settings.php +++ b/lang/ko/settings.php @@ -43,6 +43,11 @@ return [ 'local' => '로컬', 's3' => 'Amazon S3', ], + 'public_asset' => [ + 'none' => '사용 안 함 (스트리밍)', + 'public' => 'Public 디스크', + 's3' => 'Amazon S3', + ], 'cache' => [ 'file' => '파일', 'redis' => 'Redis', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 0958393b..51d273ba 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -1066,6 +1066,9 @@ return [ 'log_days_min' => '로그 보관 일수는 1 이상이어야 합니다.', 'log_days_max' => '로그 보관 일수는 365를 초과할 수 없습니다.', + // 공개 자산 디스크 설정 + 'public_asset_disk_invalid' => '올바른 공개 자산 디스크를 선택해주세요.', + // 드라이버 조건부 필수 메시지 (선택 드라이버에 따라 필수) 's3_bucket_required' => 'S3 버킷 이름은 필수입니다.', 's3_access_key_required' => 'S3 Access Key는 필수입니다.', @@ -1293,5 +1296,6 @@ return [ 'log_driver' => '로그 드라이버', 'log_level' => '로그 레벨', 'log_days' => '로그 보관 일수', + 'public_asset_disk' => '공개 자산 디스크', ], ]; diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 39cc22c1..b412380e 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -9,6 +9,8 @@ ### Changed - 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다. +- 첨부 미리보기·작성자 아바타 주소 조립을 코어와 공유하는 단일 지점으로 정리했습니다. 표시되는 주소와 동작은 종전과 동일합니다. +- 코어 최소 요구 버전을 7.0.7 로 상향했습니다. ### Fixed diff --git a/modules/_bundled/sirsoft-board/docs/api/board.md b/modules/_bundled/sirsoft-board/docs/api/board.md index 209a90a0..321b6b63 100644 --- a/modules/_bundled/sirsoft-board/docs/api/board.md +++ b/modules/_bundled/sirsoft-board/docs/api/board.md @@ -351,7 +351,7 @@ _목록 응답: `data.data[]` 배열 항목의 필드 + `data.pagination`._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | @@ -1029,7 +1029,7 @@ _단건 응답: `data` 객체의 필드._ | reply_count | integer | `0` | reply 개수 (집계) | | attachment_count | integer | `0` | attachment 개수 (집계) | | has_attachment | boolean | `false` | attachment 여부 | -| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 | +| thumbnail | string | `/api/modules/sirsoft-board/boards/api…` | 썸네일 이미지 URL/경로 — `/api/modules/sirsoft-board/boards/{slug}/attachment/{hash}/preview` 형식 (첫 이미지 첨부의 미리보기 서빙 URL) | | parent_id | null | `null` | parent 식별자 (연관 리소스 참조) | | depth | integer | `0` | 계층 트리에서의 깊이 (0 = 최상위, 하위로 갈수록 증가) | | is_reply | boolean | `false` | reply 여부 | diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index d8cd12d8..519aac98 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -11,7 +11,7 @@ "ko": "게시판 관리를 위한 모듈", "en": "Module for board management" }, - "g7_version": ">=7.0.6", + "g7_version": ">=7.0.7", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php index e50baa6b..9a570876 100644 --- a/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php +++ b/modules/_bundled/sirsoft-board/src/Http/Resources/PostResource.php @@ -312,7 +312,7 @@ class PostResource extends BaseApiResource $slug = request()->route('slug') ?? ($this->relationLoaded('board') ? $this->board?->slug : null); if ($slug && $attachment->hash) { - return '/api/modules/sirsoft-board/boards/'.$slug.'/attachment/'.$attachment->hash.'/preview'; + return $attachment->previewUrlForSlug($slug); } return null; diff --git a/modules/_bundled/sirsoft-board/src/Models/Attachment.php b/modules/_bundled/sirsoft-board/src/Models/Attachment.php index dd8c06ca..4fb5c537 100644 --- a/modules/_bundled/sirsoft-board/src/Models/Attachment.php +++ b/modules/_bundled/sirsoft-board/src/Models/Attachment.php @@ -127,6 +127,8 @@ class Attachment extends Model /** * 게시판과의 관계를 정의합니다. + * + * @return BelongsTo 게시판 관계 */ public function board(): BelongsTo { @@ -135,6 +137,8 @@ class Attachment extends Model /** * 게시글과의 관계를 정의합니다. + * + * @return BelongsTo 게시글 관계 */ public function post(): BelongsTo { @@ -143,6 +147,8 @@ class Attachment extends Model /** * 업로더와의 관계를 정의합니다. + * + * @return BelongsTo 업로더 사용자 관계 */ public function creator(): BelongsTo { @@ -178,7 +184,21 @@ class Attachment extends Model ? $this->board->slug : Board::find($this->board_id)?->slug; - return '/api/modules/sirsoft-board/boards/'.$boardSlug.'/attachment/'.$this->hash.'/preview'; + return $this->previewUrlForSlug((string) $boardSlug); + } + + /** + * 슬러그가 이미 해석된 컨텍스트의 미리보기 URL 조립 단일 지점. + * + * 목록 직렬화(PostResource)처럼 slug 를 라우트/관계에서 이미 알고 있어 + * Board::find() 재조회(N+1)를 피해야 하는 호출측이 사용합니다. + * + * @param string $slug 게시판 슬러그 + * @return string 미리보기 URL + */ + public function previewUrlForSlug(string $slug): string + { + return '/api/modules/sirsoft-board/boards/'.$slug.'/attachment/'.$this->hash.'/preview'; } /** diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php index 83915bbb..7bcaf162 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php @@ -180,6 +180,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function filterByType(string $type): Collection { + // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다 return Board::where('type', $type)->get(); } @@ -203,6 +204,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function getRecentPosts(int $limit): array { + // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다 $activeBoardIds = Board::where('is_active', true)->pluck('id'); if ($activeBoardIds->isEmpty()) { @@ -400,6 +402,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function getTotalCommentsCount(): int { + // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다 $activeBoardIds = Board::where('is_active', true)->pluck('id'); return Comment::query() @@ -424,6 +427,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function getPopularPosts(string $period, int $limit): array { + // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다 $activeBoardIds = Board::where('is_active', true)->pluck('id'); if ($activeBoardIds->isEmpty()) { @@ -486,10 +490,10 @@ class BoardRepository implements BoardRepositoryInterface $viewCount = $post->view_count ?? 0; $commentCount = $post->comments_count ?? 0; - // Avatar URL 결정 + // Avatar URL 결정 — 코어 첨부 URL 조립은 코어 모델 단일 지점에 위임 $avatarUrl = null; if ($post->user_id && $post->attachment_hash) { - $avatarUrl = '/api/attachment/'.$post->attachment_hash; + $avatarUrl = Attachment::urlForHash($post->attachment_hash); } // 사용자 상태 처리 (탈퇴한 사용자는 익명화) @@ -560,6 +564,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function getActiveBoardsList(): Collection { + // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다 return Board::where('is_active', true) ->orderBy('created_at', 'asc') ->get(); @@ -620,6 +625,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection { + // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다 return Board::where('is_active', true) ->orderBy($orderBy, $orderDirection) ->get(); @@ -648,6 +654,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function getActiveBoardsForMenu(): Collection { + // audit:allow query-unbounded-get reason: boards 는 운영자 등록 설정성 테이블 — 행 수가 운영자 행위에 묶여 데이터 증가에 비례하지 않는다 return Board::where('is_active', true) ->select(['id', 'name', 'slug']) ->orderBy('created_at', 'asc') diff --git a/modules/_bundled/sirsoft-board/tests/Unit/AttachmentPreviewUrlTest.php b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentPreviewUrlTest.php new file mode 100644 index 00000000..4f18582f --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/AttachmentPreviewUrlTest.php @@ -0,0 +1,46 @@ +hash = 'abc123def456'; + + $this->assertSame( + '/api/modules/sirsoft-board/boards/notice/attachment/abc123def456/preview', + $attachment->previewUrlForSlug('notice') + ); + } + + #[Test] + public function preview_url_accessor_delegates_to_single_assembly_point(): void + { + $attachment = new Attachment; + $attachment->hash = 'abc123def456'; + $attachment->mime_type = 'image/png'; + $attachment->board_id = 0; + + // board 관계 미로딩 + 존재하지 않는 board_id → slug 는 빈 문자열로 조립 (기존 동작 보존) + $this->assertSame( + '/api/modules/sirsoft-board/boards//attachment/abc123def456/preview', + $attachment->preview_url + ); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositoryPopularAvatarTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositoryPopularAvatarTest.php new file mode 100644 index 00000000..5f8153b2 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositoryPopularAvatarTest.php @@ -0,0 +1,77 @@ +create(); + + $avatar = CoreAttachment::create([ + 'attachmentable_type' => User::class, + 'attachmentable_id' => $user->id, + 'original_filename' => 'avatar.png', + 'stored_filename' => 'stored-avatar.png', + 'disk' => 'local', + 'path' => 'avatars/avatar.png', + 'mime_type' => 'image/png', + 'size' => 100, + 'collection' => 'avatar', + ]); + + $postId = $this->createTestPost([ + 'user_id' => $user->id, + 'view_count' => 99, + 'comments_count' => 0, + 'replies_count' => 0, + 'attachments_count' => 0, + ]); + + $posts = app(BoardRepository::class)->getPopularPosts('year', 10); + + $target = collect($posts)->firstWhere('id', $postId); + $this->assertNotNull($target, '생성한 게시글이 인기 게시물 목록에 있어야 합니다'); + $this->assertSame('/api/attachment/'.$avatar->hash, $target['author']['avatar']); + } + + /** + * @effects board_response_strings_unchanged + */ + #[Test] + public function popular_posts_avatar_is_null_without_avatar_attachment(): void + { + $user = User::factory()->create(); + + $postId = $this->createTestPost([ + 'user_id' => $user->id, + 'view_count' => 98, + 'comments_count' => 0, + 'replies_count' => 0, + 'attachments_count' => 0, + ]); + + $posts = app(BoardRepository::class)->getPopularPosts('year', 10); + + $target = collect($posts)->firstWhere('id', $postId); + $this->assertNotNull($target); + $this->assertNull($target['author']['avatar']); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php new file mode 100644 index 00000000..38120b6d --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php @@ -0,0 +1,78 @@ +createTestPost(); + + $attachment = Attachment::create([ + 'board_id' => $this->board->id, + 'post_id' => $postId, + 'original_filename' => 'thumb.jpg', + 'stored_filename' => 'stored-thumb.jpg', + 'disk' => 'modules', + 'path' => 'attachments/thumb.jpg', + 'mime_type' => $mimeType, + 'size' => 100, + 'collection' => 'attachments', + 'order' => 0, + ]); + + $post = Post::with(['thumbnailAttachment', 'board'])->findOrFail($postId); + + return ['post' => $post, 'attachment' => $attachment]; + } + + /** + * @effects board_response_strings_unchanged + */ + #[Test] + public function thumbnail_serializes_preview_url_with_board_slug(): void + { + ['post' => $post, 'attachment' => $attachment] = $this->createPostWithAttachment('image/jpeg'); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertSame( + '/api/modules/sirsoft-board/boards/'.$this->board->slug.'/attachment/'.$attachment->hash.'/preview', + $response['thumbnail'], + ); + } + + /** + * @effects board_response_strings_unchanged + */ + #[Test] + public function thumbnail_is_null_for_non_image_attachment(): void + { + ['post' => $post] = $this->createPostWithAttachment('application/pdf'); + + $response = (new PostResource($post))->toArray(Request::create('/')); + + $this->assertNull($response['thumbnail']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 4591aff7..0891de87 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -9,6 +9,11 @@ ### Added - 리뷰 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 상품·카테고리 이미지와 동일하게 리뷰 이미지도 업로드 시점에 변환(압축·리사이즈·포맷 변경)할 수 있습니다. (#96 @lyg-kaban 님께서 건의해주셨습니다.) +- 상품·카테고리·리뷰 이미지를 코어의 공개 자산 스토리지(S3+CDN 등) 디스크에 저장하고 화면에는 CDN 직접 주소로 서빙할 수 있습니다. 기본정보 탭의 「공개 자산 스토리지」에서 이 모듈만 다른 디스크를 쓰도록 개별 설정할 수도 있습니다(비우면 코어 설정 따름). 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시·삭제됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.) + +### Changed + +- 코어 최소 요구 버전을 7.0.7 로 상향했습니다. ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php index 4aef2a12..3fdd51cc 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php +++ b/modules/_bundled/sirsoft-ecommerce/config/ecommerce.php @@ -66,16 +66,6 @@ return [ 'guest_cart_lifetime' => 7 * 24 * 60, // 7일 (분 단위) ], - // 카테고리 설정 - 'category' => [ - 'image_disk' => 'local', - ], - - // 브랜드 설정 - 'brand' => [ - 'image_disk' => 'local', - ], - // 리뷰 설정 'review' => [ 'write_deadline_days' => 90, // 구매확정 후 리뷰 작성 가능 기간 (일) diff --git a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json index f2b209eb..5b6da90d 100644 --- a/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json +++ b/modules/_bundled/sirsoft-ecommerce/config/settings/defaults.json @@ -23,7 +23,8 @@ "privacy_officer": "", "privacy_officer_email": "", "mail_order_number": "", - "telecom_number": "" + "telecom_number": "", + "public_asset_disk": "" }, "language_currency": { "default_currency": "KRW", @@ -339,7 +340,8 @@ "privacy_officer": { "expose": false }, "privacy_officer_email": { "expose": false }, "mail_order_number": { "expose": false }, - "telecom_number": { "expose": false } + "telecom_number": { "expose": false }, + "public_asset_disk": { "expose": false } } }, "language_currency": { diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md index 01c4b18c..51f96552 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/settings.md @@ -54,6 +54,7 @@ _단건 응답: `data` 객체의 필드._ | claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) | | available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) | | available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) | +| available_public_asset_disks | array | `[{"id":"none","label":{"ko":"사용 안 함 (스트리밍)","en":"Disabl…` | 공개 자산 직접 URL 서빙 디스크 선택지 (코어 DriverRegistryService 카탈로그 — none/public/s3 + 플러그인 훅 등록분). 기본정보 탭의 공개 자산 디스크 Select 옵션 소스 | | abilities | object | `{"can_update":true}` | 현재 사용자가 이 리소스에 수행 가능한 작업 불리언 맵 (can_update, can_delete 등 — 권한 맵 기반) | | _meta | object | `{"limits":{"auto_cancel_days_min":1,"auto_cancel_days_max…` | 설정 화면 전용 메타. `limits` 는 숫자 입력의 경계값 맵(`config('sirsoft-ecommerce.limits')`)으로, 저장 규칙(FormRequest)과 같은 출처다 — 화면이 리터럴 경계를 들면 규칙 변경을 따라가지 못해 "화면은 받는데 저장은 422" 가 되므로 입력 min/max 는 이 값을 바인딩한다 | @@ -449,6 +450,7 @@ HTTP/1.1 200 | basic_info.privacy_officer_email | body | email | 아니오 | max 255 | 개인정보 보호책임자 E-mail | | basic_info.mail_order_number | body | string | 아니오 | max 100 | 통신판매업 신고번호 | | basic_info.telecom_number | body | string | 아니오 | max 100 | 부가통신 사업자번호 | +| basic_info.public_asset_disk | body | string | 아니오 | max 100 | 공개 자산 디스크 오버라이드 (빈값=코어 설정 따름, none=강제 스트리밍, 그 외=카탈로그 디스크 id — 존재하지 않는 디스크는 스트리밍으로 안전 폴백) | | language_currency | body | array | 아니오 | — | 통화 설정 섹션 (기본 통화·통화 목록: 코드·다국어명·환율·반올림 규칙·통화별 로케일) | | language_currency.default_currency | body | string | 아니오 | max 10 | 쇼핑몰 기본(base) 통화 코드. 상품/주문이 1건이라도 생성된 뒤에는 변경 불가 | | language_currency.currencies | body | array | 아니오 | — | 등록 통화 목록. 항목별 `code`(ISO 4217 3자리 대문자, 필수)·`name`(다국어 배열, 필수)·`symbol`·`exchange_rate`·`base_unit`·`rounding_unit`·`rounding_method`(`floor`\|`round`\|`ceil`)·`decimal_places`·`is_default`·`locales` | @@ -667,6 +669,7 @@ _단건 응답: `data` 객체의 필드._ | claim | object | `{"refund_reasons":[{"id":1,"type":"refund","code":"order_…` | 클레임 설정 (DB 관리 대상인 환불 사유 목록: 코드·다국어명·귀책 유형·노출/활성 여부) | | available_pg_providers | array | `[{"id":"kginicis","name_key":"sirsoft-pay_kginicis::provi…` | 설치된 PG 플러그인이 훅으로 등록한 PG 제공자 목록 (id·name_key·지원 결제수단) | | available_cash_receipt_providers | array | `[]` | 설치된 플러그인이 훅으로 등록한 현금영수증 발급 제공자 목록 (id·name_key — 미등록 시 빈 배열이며 신청 폼이 노출되지 않음) | +| available_public_asset_disks | array | `[{"id":"none","label":{"ko":"사용 안 함 (스트리밍)","en":"Disabl…` | 공개 자산 직접 URL 서빙 디스크 선택지 (코어 DriverRegistryService 카탈로그 — none/public/s3 + 플러그인 훅 등록분). 기본정보 탭의 공개 자산 디스크 Select 옵션 소스 | **응답 예시** diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 5ae6835f..697c7767 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -11,7 +11,7 @@ "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", "en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management" }, - "g7_version": ">=7.0.6", + "g7_version": ">=7.0.7", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-ecommerce/module.php b/modules/_bundled/sirsoft-ecommerce/module.php index e7cb30b4..4a359b0b 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.php +++ b/modules/_bundled/sirsoft-ecommerce/module.php @@ -1056,6 +1056,43 @@ class Module extends AbstractModule ]; } + /** + * 완전 공개 자산 스토리지 카테고리 목록 + * + * 이 카테고리들만 공개 자산 디스크 설정을 따른다. 권한 검사가 걸린 자산 + * (회원 전용/비밀 자료 등)은 직접 URL 이 권한을 우회하므로 포함하지 않는다. + * 새 공개 자산 카테고리가 생기면 여기에만 추가하면 된다. + * + * @var list + */ + private const PUBLIC_ASSET_CATEGORIES = ['images']; + + /** + * 카테고리별 스토리지 디스크 이름 반환 + * + * 완전 공개 자산 카테고리만 공개 자산 디스크(모듈 설정 basic_info.public_asset_disk > + * 코어 전역 core.storage.public_asset_disk)를 따르고, 설정/캐시 등 나머지 + * 카테고리는 기본 디스크를 유지합니다. 미설정/고아 디스크는 기본 디스크로 + * 폴백해 기존 스트리밍 동작을 보존합니다. + * + * 모듈 설정 조회는 공개 자산 카테고리에서만 수행합니다 — 'settings' 카테고리에서 + * 조회하면 설정 로드와 재귀 고리가 생깁니다 (AbstractModule 주석 참조). + * + * @param string $category 카테고리 + * @return string 디스크 이름 + */ + public function getStorageDiskFor(string $category): string + { + if (! in_array($category, self::PUBLIC_ASSET_CATEGORIES, true)) { + return $this->getStorageDisk(); + } + + $override = module_setting('sirsoft-ecommerce', 'basic_info.public_asset_disk', ''); + + return $this->resolvePublicAssetDisk(is_string($override) ? $override : '') + ?? $this->getStorageDisk(); + } + /** * 모듈 설치 시 실행할 시더 목록 반환 * diff --git a/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsPublicAssetDisk.test.tsx b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsPublicAssetDisk.test.tsx new file mode 100644 index 00000000..d05758f1 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsPublicAssetDisk.test.tsx @@ -0,0 +1,249 @@ +/** + * 이커머스 설정 기본정보 탭 — 공개 자산 디스크 오버라이드 필드 테스트 (공개#100) + * + * @description + * 모듈 오버라이드 필드는 저장 키(basic_info.public_asset_disk)와 설정 응답의 + * 카탈로그(available_public_asset_disks)를 바인딩해야 한다. 세 지점(defaults.json · + * StoreEcommerceSettingsRequest · 레이아웃 name)이 같은 키를 가리키지 않으면 + * 저장값이 조용히 버려지거나(whitelist 탈락) 화면만 있고 저장이 없는 상태가 된다. + * + * 계약 단언에 더해, 실제 카드 서브트리를 createLayoutTest 로 렌더링해 + * "코어 설정 따름" 선두 옵션 + 카탈로그 $localized 매핑 계산까지 검증한다. + * + * @effects settings_catalog_includes_plugin_registered_disks + */ + +import React from 'react'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +import tabBasicInfo from '../../../layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json'; + +// ─── 테스트용 컴포넌트 ─── + +const TestDiv: React.FC<{ className?: string; children?: React.ReactNode; 'data-testid'?: string }> = + ({ className, children, 'data-testid': testId }) => ( +
{children}
+ ); + +const TestH3: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) =>

{children || text}

; + +const TestLabel: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) => ; + +const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) => {children || text}; + +const TestSelect: React.FC<{ + name?: string; value?: string; className?: string; options?: any[]; disabled?: boolean; + error?: string; onChange?: (e: React.ChangeEvent) => void; +}> = ({ name, value, className, options, disabled, onChange }) => ( + +); + +// 렌더러가 컴포넌트 목록을 Fragment 로 감싸므로 반드시 등록해야 한다. +const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}; + +function setupTestRegistry(): ComponentRegistry { + const registry = ComponentRegistry.getInstance(); + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } }, + Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; + return registry; +} + +// ─── 다국어 (기본정보 탭 공개 자산 카드가 쓰는 키) ─── + +const translations = { + 'sirsoft-ecommerce': { + admin: { + settings: { + basic_info: { + public_asset_storage: { + title: '공개 자산 스토리지', + description: '이 모듈의 이미지 서빙 디스크를 오버라이드합니다.', + disk: '공개 자산 디스크', + follow_core: '코어 설정 따름', + help: '미설정 시 코어 전역 설정을 따릅니다.', + }, + }, + }, + }, + }, +}; + +/** + * 모듈 루트(모듈 manifest 기준)를 위로 훑어 찾는다. + * + * @returns 모듈 루트 절대경로 + */ +function moduleRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'module.json'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('module.json 을 가진 모듈 루트를 찾지 못했습니다.'); +} + +/** + * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다. + * + * @param node 탐색 시작 노드 + * @param predicate 노드 판정 함수 + * @returns 찾은 노드 또는 null + */ +function findNode(node: any, predicate: (n: any) => boolean): any { + if (!node || typeof node !== 'object') return null; + if (predicate(node)) return node; + + for (const child of node.children ?? []) { + const found = findNode(child, predicate); + if (found) return found; + } + + return null; +} + +// 실제 카드 서브트리를 그대로 렌더 대상으로 쓴다 — 사본 표현식이 아닌 배포 JSON 자체가 SSoT. +const publicAssetCard = findNode( + tabBasicInfo, + (n) => n.id === 'public_asset_storage_card', +); + +const CATALOG = [ + { id: 'none', label: { ko: '사용 안 함 (스트리밍)', en: 'None (streaming)' } }, + { id: 'fake_cdn', label: { ko: '가짜 CDN', en: 'Fake CDN' } }, +]; + +/** + * 카드 렌더링용 테스트 유틸을 생성한다. + * + * @returns createLayoutTest 유틸 + */ +function makeUtils() { + return createLayoutTest( + { + version: '1.0.0', + layout_name: 'test_ecommerce_public_asset_card', + components: [publicAssetCard], + } as any, + { + auth: { + isAuthenticated: true, + user: { id: 1, name: 'Admin', role: 'super_admin' }, + authType: 'admin', + }, + translations, + locale: 'ko', + initialState: { + _local: { + form: { + available_public_asset_disks: CATALOG, + basic_info: { public_asset_disk: '' }, + }, + errors: null, + }, + }, + }, + ); +} + +describe('이커머스 공개 자산 디스크 오버라이드 필드 — 계약', () => { + it('Select 가 저장 키와 카탈로그를 바인딩하고 코어 따름 기본 옵션을 갖는다', () => { + const select = findNode( + publicAssetCard, + (n) => n.name === 'Select' && n.props?.name === 'basic_info.public_asset_disk', + ); + + expect(select).not.toBeNull(); + expect(select.props.options).toContain('available_public_asset_disks'); + expect(select.props.options).toContain('follow_core'); + }); + + it('defaults.json 과 저장 규칙이 같은 키를 선언한다', () => { + const root = moduleRoot(); + const defaults = JSON.parse( + fs.readFileSync(path.join(root, 'config/settings/defaults.json'), 'utf-8'), + ); + const requestSource = fs.readFileSync( + path.join(root, 'src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php'), + 'utf-8', + ); + + expect(defaults.defaults.basic_info).toHaveProperty('public_asset_disk'); + expect(requestSource).toContain("'basic_info.public_asset_disk'"); + }); + + it('module.php 오버라이드가 같은 설정 키를 조회한다', () => { + const moduleSource = fs.readFileSync(path.join(moduleRoot(), 'module.php'), 'utf-8'); + + expect(moduleSource).toContain("'basic_info.public_asset_disk'"); + expect(moduleSource).toContain('PUBLIC_ASSET_CATEGORIES'); + }); +}); + +describe('이커머스 공개 자산 디스크 오버라이드 필드 — 렌더링', () => { + let registry: ComponentRegistry; + let testUtils: ReturnType | null = null; + + beforeEach(() => { + registry = setupTestRegistry(); + // 표현식 내 $t() 는 컨텍스트에 $templateId 가 없으면 __templateApp.getConfig() + // 로 회수한다(engine-v1.38.2 폴백). layoutTestUtils 는 이를 세팅하지 않으므로 + // 테스트에서 동일 폴백 경로를 제공해야 옵션 계산식의 $t() 가 해석된다. + (window as any).__templateApp = { + getConfig: () => ({ templateId: 'test-template', locale: 'ko' }), + }; + }); + + afterEach(() => { + testUtils?.cleanup(); + testUtils = null; + (registry as any).registry = {}; + delete (window as any).__templateApp; + }); + + it('카드 제목/도움말이 다국어 해석되어 렌더된다', async () => { + testUtils = makeUtils(); + const { container } = await testUtils.render(); + + expect(container.querySelector('h3.card-title')?.textContent ?? '').toContain( + '공개 자산 스토리지', + ); + expect(container.textContent ?? '').toContain('미설정 시 코어 전역 설정을 따릅니다.'); + expect(() => testUtils!.assertNoValidationErrors()).not.toThrow(); + }); + + it('Select 옵션이 코어 따름 선두 + 카탈로그 $localized 매핑으로 계산된다', async () => { + testUtils = makeUtils(); + const { container } = await testUtils.render(); + + const select = container.querySelector('select[data-name="basic_info.public_asset_disk"]'); + expect(select).not.toBeNull(); + + const options = Array.from(select!.querySelectorAll('option')); + expect(options.map((o) => o.getAttribute('value'))).toEqual(['', 'none', 'fake_cdn']); + expect(options.map((o) => o.textContent)).toEqual([ + '코어 설정 따름', + '사용 안 함 (스트리밍)', + '가짜 CDN', + ]); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json index d7d44b99..a4b2be4d 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/en/admin/settings.json @@ -59,6 +59,13 @@ "telecom_number_placeholder": "Telecom business number", "postcode_plugin_required": "Daum Postcode plugin is not installed or inactive. Please activate it in Admin > Plugins." }, + "public_asset_storage": { + "title": "Public Asset Storage", + "description": "Configure the storage disk for this module's fully public assets such as product, category, and review images.", + "disk": "Public Asset Disk", + "follow_core": "Follow core setting", + "help": "Select to use a different disk for this module only. Leave empty to follow the public asset storage setting in core system settings." + }, "inquiry_settings": { "title": "1:1 Inquiry Board Settings", "description": "Configure the product 1:1 inquiry feature.", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json index e792c594..98d665a9 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/lang/partial/ko/admin/settings.json @@ -59,6 +59,13 @@ "telecom_number_placeholder": "부가통신 사업자번호", "postcode_plugin_required": "다음 우편번호 플러그인이 설치되지 않았거나 비활성화 상태입니다. 관리자 > 플러그인에서 활성화해주세요." }, + "public_asset_storage": { + "title": "공개 자산 스토리지", + "description": "상품·카테고리·리뷰 이미지 등 이 모듈의 완전 공개 자산 저장 디스크를 설정합니다.", + "disk": "공개 자산 디스크", + "follow_core": "코어 설정 따름", + "help": "이 모듈만 다른 디스크를 쓰려면 선택합니다. 비우면 코어 환경설정의 공개 자산 스토리지 설정을 따릅니다." + }, "inquiry_settings": { "title": "1:1 문의게시판 설정", "description": "상품 1:1 문의 기능을 설정합니다.", diff --git a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json index e2c39841..f9057863 100644 --- a/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json +++ b/modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_basic_info.json @@ -1055,6 +1055,89 @@ } ] }, + { + "id": "public_asset_storage_card", + "type": "basic", + "name": "Div", + "props": { + "className": "admin-card" + }, + "children": [ + { + "id": "public_asset_storage_title", + "type": "basic", + "name": "H3", + "props": { + "className": "card-title" + }, + "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.public_asset_storage.title" + }, + { + "id": "public_asset_storage_description", + "type": "basic", + "name": "Div", + "props": { + "className": "card-description" + }, + "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.public_asset_storage.description" + }, + { + "id": "field_public_asset_disk", + "type": "basic", + "name": "Div", + "props": { + "className": "form-group" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.public_asset_storage.disk" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "w-full md:w-64" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "basic_info.public_asset_disk", + "disabled": "{{_computed.isReadOnly}}", + "className": "w-full", + "error": "{{_local.errors?.['basic_info.public_asset_disk']?.[0] ?? ''}}", + "options": "{{[{value: '', label: $t('sirsoft-ecommerce.admin.settings.basic_info.public_asset_storage.follow_core')}].concat((_local.form?.available_public_asset_disks ?? []).map(d => ({value: d.id, label: $localized(d.label)})))}}" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['basic_info.public_asset_disk']}}", + "props": { + "className": "form-error" + }, + "text": "{{_local.errors?.['basic_info.public_asset_disk']?.[0] ?? ''}}" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "text-sm text-gray-500 dark:text-gray-400 mt-2" + }, + "text": "$t:sirsoft-ecommerce.admin.settings.basic_info.public_asset_storage.help" + } + ] + } + ] + } + ] + }, { "id": "inquiry_settings_card", "type": "basic", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php index c3ca608d..92156277 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php @@ -7,6 +7,7 @@ use App\Helpers\PermissionHelper; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Seo\Contracts\SeoCacheManagerInterface; +use App\Services\DriverRegistryService; use App\Services\NotificationChannelService; use App\Services\NotificationDefinitionService; use Exception; @@ -56,6 +57,9 @@ class EcommerceSettingsController extends AdminBaseController // 현금영수증 발급 프로바이더 후보 — 결제 플러그인이 훅으로 자신을 등록한다. // 발급 PG 와 결제 PG 는 독립 선택이므로 목록도 별도로 내린다 (KG 결제 + 토스 발급 등). $settings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders(); + // 공개 자산 디스크 선택지 — 카탈로그 SSoT 는 코어 DriverRegistryService 게터 한 곳 + $settings['available_public_asset_disks'] = app(DriverRegistryService::class) + ->getAvailableDrivers('public_asset'); $settings['abilities'] = [ 'can_update' => PermissionHelper::check('sirsoft-ecommerce.settings.update', request()->user()), ]; @@ -171,6 +175,9 @@ class EcommerceSettingsController extends AdminBaseController $updatedSettings = $this->appendMileageNotificationChannelsToSettings($updatedSettings); $updatedSettings['available_pg_providers'] = $this->settingsService->getRegisteredPgProviders(); $updatedSettings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders(); + // 공개 자산 디스크 선택지 — 저장 응답에도 재부착 (관리자 UI 상태 갱신용) + $updatedSettings['available_public_asset_disks'] = app(DriverRegistryService::class) + ->getAvailableDrivers('public_asset'); return ResponseHelper::moduleSuccess( 'sirsoft-ecommerce', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php index 2342321c..99f8602e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Admin/StoreEcommerceSettingsRequest.php @@ -3,9 +3,9 @@ namespace Modules\Sirsoft\Ecommerce\Http\Requests\Admin; use App\Rules\LocaleRequiredTranslatable; +use App\Services\DriverRegistryService; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Validator; -use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum; use Modules\Sirsoft\Ecommerce\Enums\ShippingFeeTaxPolicy; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; @@ -206,6 +206,13 @@ class StoreEcommerceSettingsRequest extends FormRequest 'basic_info.privacy_officer_email' => ['nullable', 'email', 'max:255'], 'basic_info.mail_order_number' => ['nullable', 'string', 'max:100'], 'basic_info.telecom_number' => ['nullable', 'string', 'max:100'], + // 공개 자산 디스크 — 빈 값은 코어 공개 자산 디스크 설정을 따른다. + // 선택지가 코어 카탈로그 + 플러그인 훅 등록 디스크로 동적이라 정적 in 불가 — + // 코어 SaveSettingsRequest 와 동일하게 카탈로그 조회 closure 로 검증한다. + // (런타임 resolvePublicAssetDisk() 의 스트리밍 폴백은 최후 안전망이지, 저장 + // 시점의 오타를 성공 응답으로 통과시켜도 된다는 뜻이 아니다 — 운영자에게는 + // "저장됐는데 CDN 이 안 붙는" 무증상 상태로만 보인다.) + 'basic_info.public_asset_disk' => ['nullable', 'string', 'max:100', $this->publicAssetDiskRule()], 'language_currency' => ['sometimes', 'array'], // default_language 제거 (A1-⑤, D-LANG): 사이트 언어는 코어 일반설정으로 일원화. 모듈 orphan 필드. @@ -364,6 +371,28 @@ class StoreEcommerceSettingsRequest extends FormRequest ]; } + /** + * 공개 자산 디스크 존재 검증 규칙을 만듭니다. + * + * 선택지는 코어 3종 + 플러그인이 훅으로 등록한 디스크라 런타임에만 확정되므로 + * 정적 `Rule::in` 을 쓸 수 없습니다. 코어 `SaveSettingsRequest` 와 동일하게 + * 카탈로그 게터(SSoT)를 조회하는 closure 로 검증해 두 표면의 강도를 맞춥니다. + * + * @return \Closure 검증 closure + */ + protected function publicAssetDiskRule(): \Closure + { + return function (string $attribute, mixed $value, \Closure $fail): void { + if ($value === null || $value === '') { + return; + } + + if (! app(DriverRegistryService::class)->isDriverAvailable('public_asset', $value)) { + $fail(__('sirsoft-ecommerce::validation.public_asset_disk_invalid')); + } + }; + } + /** * 검증 속성명 다국어 처리 * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UploadReviewImageRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UploadReviewImageRequest.php index 15799225..b29c0fc5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UploadReviewImageRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/User/UploadReviewImageRequest.php @@ -67,7 +67,8 @@ class UploadReviewImageRequest extends FormRequest return (int) module_setting( 'sirsoft-ecommerce', 'review_settings.max_image_size_mb', - config('ecommerce.review.max_image_size_mb', 10) + // 모듈 config 는 'sirsoft-ecommerce' 네임스페이스로 로드된다 (ModuleManager::loadModuleConfig) + config('sirsoft-ecommerce.review.max_image_size_mb', 10) ); } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/CategoryImage.php b/modules/_bundled/sirsoft-ecommerce/src/Models/CategoryImage.php index 24d88cd2..e8eeb966 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/CategoryImage.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/CategoryImage.php @@ -6,12 +6,14 @@ use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; +use Modules\Sirsoft\Ecommerce\Models\Concerns\HasDirectAssetUrl; /** * 카테고리 이미지 모델 */ class CategoryImage extends Model { + use HasDirectAssetUrl; use SoftDeletes; protected $table = 'ecommerce_category_images'; @@ -108,11 +110,11 @@ class CategoryImage extends Model } /** - * `download_url` 가상 attribute — hash 기반 카테고리 이미지 서빙 API URL. + * hash 기반 카테고리 이미지 서빙 API URL (직접 URL 불가 시 폴백). * * @return string `/api/modules/sirsoft-ecommerce/category-image/{hash}` 형식 URL */ - public function getDownloadUrlAttribute(): string + protected function apiDownloadUrl(): string { return '/api/modules/sirsoft-ecommerce/category-image/'.$this->hash; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php b/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php new file mode 100644 index 00000000..4e3a06f8 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/Concerns/HasDirectAssetUrl.php @@ -0,0 +1,65 @@ +resolveDirectUrl() ?? $this->apiDownloadUrl(); + } + + /** + * 행 disk 기준 직접 URL 해석을 시도합니다. + * + * @return string|null 직접 URL (불가하면 null) + */ + protected function resolveDirectUrl(): ?string + { + $disk = (string) ($this->disk ?? ''); + $path = (string) ($this->path ?? ''); + + if ($disk === '' || $path === '') { + return null; + } + + return AssetStorage::forDisk($disk)->url($this->directUrlCategory(), $path); + } + + /** + * 직접 URL 해석에 쓸 스토리지 카테고리를 반환합니다. + * + * @return string 스토리지 카테고리 (기본 'images') + */ + protected function directUrlCategory(): string + { + return 'images'; + } + + /** + * 기존 API 서빙 경로를 반환합니다 (직접 URL 불가 시 폴백). + * + * @return string API 서빙 URL + */ + abstract protected function apiDownloadUrl(): string; +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/ProductImage.php b/modules/_bundled/sirsoft-ecommerce/src/Models/ProductImage.php index 108e8afa..b9b32042 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/ProductImage.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/ProductImage.php @@ -3,16 +3,19 @@ namespace Modules\Sirsoft\Ecommerce\Models; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Modules\Sirsoft\Ecommerce\Enums\ProductImageCollection; +use Modules\Sirsoft\Ecommerce\Models\Concerns\HasDirectAssetUrl; /** * 상품 이미지 모델 */ class ProductImage extends Model { + use HasDirectAssetUrl; use SoftDeletes; protected $table = 'ecommerce_product_images'; @@ -116,9 +119,9 @@ class ProductImage extends Model /** * 특정 컬렉션(main/gallery/detail 등) 의 이미지만 필터링하는 쿼리 스코프. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param Builder $query * @param ProductImageCollection $collection 필터링할 이미지 컬렉션 enum - * @return \Illuminate\Database\Eloquent\Builder + * @return Builder */ public function scopeByCollection($query, ProductImageCollection $collection) { @@ -128,8 +131,8 @@ class ProductImage extends Model /** * 대표 이미지만 조회 * - * @param \Illuminate\Database\Eloquent\Builder $query - * @return \Illuminate\Database\Eloquent\Builder + * @param Builder $query + * @return Builder */ public function scopeThumbnails($query) { @@ -137,11 +140,11 @@ class ProductImage extends Model } /** - * `download_url` 가상 attribute — hash 기반 상품 이미지 서빙 API URL. + * hash 기반 상품 이미지 서빙 API URL (직접 URL 불가 시 폴백). * * @return string `/api/modules/sirsoft-ecommerce/product-image/{hash}` 형식 URL */ - public function getDownloadUrlAttribute(): string + protected function apiDownloadUrl(): string { return '/api/modules/sirsoft-ecommerce/product-image/'.$this->hash; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Models/ProductReviewImage.php b/modules/_bundled/sirsoft-ecommerce/src/Models/ProductReviewImage.php index 9f3338e0..0c80a80e 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Models/ProductReviewImage.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Models/ProductReviewImage.php @@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Modules\Sirsoft\Ecommerce\Database\Factories\ProductReviewImageFactory; +use Modules\Sirsoft\Ecommerce\Models\Concerns\HasDirectAssetUrl; /** * 상품 리뷰 이미지 모델 */ class ProductReviewImage extends Model { + use HasDirectAssetUrl; use HasFactory, SoftDeletes; protected $table = 'ecommerce_product_review_images'; @@ -103,11 +105,11 @@ class ProductReviewImage extends Model } /** - * 다운로드 URL 반환 (API 서빙 URL) + * hash 기반 리뷰 이미지 서빙 API URL (직접 URL 불가 시 폴백). * * @return string */ - public function getDownloadUrlAttribute(): string + protected function apiDownloadUrl(): string { return '/api/modules/sirsoft-ecommerce/review-image/'.$this->hash; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php index 6bcc6f11..776d685f 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php @@ -119,10 +119,21 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider * @var array */ protected array $storageServices = [ - CategoryImageService::class, - ProductImageService::class, ProductReviewService::class, - ProductReviewImageService::class, + ]; + + /** + * 카테고리별 StorageInterface가 필요한 서비스 매핑 (클래스 ⇒ 카테고리) + * + * 이미지 서비스는 getStorageDiskFor('images') 가 결정한 디스크(공개 자산 디스크 + * 설정 반영)를 주입받아, put/getDisk() 행 기록이 자동으로 카테고리 디스크를 따릅니다. + * + * @var array + */ + protected array $storageCategoryServices = [ + CategoryImageService::class => 'images', + ProductImageService::class => 'images', + ProductReviewImageService::class => 'images', ]; /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php index 6a09cb8a..04260ad0 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CategoryImageService.php @@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Modules\Sirsoft\Ecommerce\Models\CategoryImage; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryImageRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Services\Concerns\ResolvesRowStorage; use Symfony\Component\HttpFoundation\StreamedResponse; /** @@ -21,6 +22,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse; */ class CategoryImageService { + use ResolvesRowStorage; + /** * CategoryImageService 생성자 * @@ -174,7 +177,7 @@ class CategoryImageService return null; } - $response = $this->storage->response( + $response = $this->storageForRow($image->disk)->response( 'images', $image->path, $image->original_filename, @@ -188,7 +191,7 @@ class CategoryImageService Log::error('카테고리 이미지 스토리지에 없음', [ 'category_image_id' => $image->id, 'path' => $image->path, - 'disk' => $this->storage->getDisk(), + 'disk' => $image->disk ?: $this->storage->getDisk(), ]); return null; @@ -218,11 +221,12 @@ class CategoryImageService // Before 훅 HookManager::doAction('sirsoft-ecommerce.category-image.before_delete', $image); - // 스토리지에서 파일 삭제 + // 스토리지에서 파일 삭제 — 행 disk 기준 // DB에 저장된 경로: category/{date}/{filename} // storage->exists/delete에 category를 전달하면 자동으로 images/ 추가됨 - if ($this->storage->exists('images', $image->path)) { - $this->storage->delete('images', $image->path); + $rowStorage = $this->storageForRow($image->disk); + if ($rowStorage->exists('images', $image->path)) { + $rowStorage->delete('images', $image->path); } // DB에서 삭제 diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/Concerns/ResolvesRowStorage.php b/modules/_bundled/sirsoft-ecommerce/src/Services/Concerns/ResolvesRowStorage.php new file mode 100644 index 00000000..c9bdfdcc --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/Concerns/ResolvesRowStorage.php @@ -0,0 +1,39 @@ +storage->getDisk()) { + return $this->storage; + } + + // 고아 disk 방어 — 디스크를 제공하던 플러그인이 비활성화되면 그 disk 가 + // config 에서 사라지는데, 미등록 disk 로 withDisk 를 만들면 이후 exists/response/ + // delete 가 InvalidArgumentException 을 던져 서빙·삭제가 500 이 된다. + // AbstractModule::resolvePublicAssetDisk() 와 동일한 존재 검증으로 주입 스토리지에 + // 폴백한다 (파일은 도달 불가이므로 404 가 정상 degradation). + if (config("filesystems.disks.{$disk}") === null) { + return $this->storage; + } + + return $this->storage->withDisk($disk); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php index 1ff0589f..d40f2b63 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductImageService.php @@ -16,6 +16,7 @@ use Modules\Sirsoft\Ecommerce\Exceptions\ResourceScopeMismatchException; use Modules\Sirsoft\Ecommerce\Models\ProductImage; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductImageRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Services\Concerns\ResolvesRowStorage; use Symfony\Component\HttpFoundation\StreamedResponse; /** @@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse; */ class ProductImageService { + use ResolvesRowStorage; + /** * ProductImageService 생성자 * @@ -193,11 +196,12 @@ class ProductImageService // 새 경로 생성: products/{productCode}/{filename} $newPath = "products/{$productCode}/{$image->stored_filename}"; - // 파일 이동 (get + put + delete) - $content = $this->storage->get('images', $image->path); + // 파일 이동 (get + put + delete) — 행 disk 기준 (이동 후에도 행 disk 불변) + $rowStorage = $this->storageForRow($image->disk); + $content = $rowStorage->get('images', $image->path); if ($content) { - $this->storage->put('images', $newPath, $content); - $this->storage->delete('images', $image->path); + $rowStorage->put('images', $newPath, $content); + $rowStorage->delete('images', $image->path); } // DB 업데이트: product_id 설정, temp_key 제거, path 변경, sort_order 재배치, is_thumbnail 해제 @@ -212,7 +216,10 @@ class ProductImageService $linkedCount++; } - // 빈 임시 디렉토리 정리 + // 빈 임시 디렉토리 정리 — 임시 행이 여러 disk 에 걸칠 수 있어 disk 별로 정리 + foreach ($tempImages->pluck('disk')->filter()->unique() as $tempDisk) { + $this->storageForRow($tempDisk)->deleteDirectory('images', "products/temp/{$tempKey}"); + } $this->storage->deleteDirectory('images', "products/temp/{$tempKey}"); return $linkedCount; @@ -262,9 +269,10 @@ class ProductImageService // Before 훅 HookManager::doAction('sirsoft-ecommerce.product-image.before_delete', $image); - // 스토리지에서 파일 삭제 - if ($this->storage->exists('images', $image->path)) { - $this->storage->delete('images', $image->path); + // 스토리지에서 파일 삭제 — 행 disk 기준 + $rowStorage = $this->storageForRow($image->disk); + if ($rowStorage->exists('images', $image->path)) { + $rowStorage->delete('images', $image->path); } // DB에서 삭제 @@ -350,7 +358,7 @@ class ProductImageService return null; } - $response = $this->storage->response( + $response = $this->storageForRow($image->disk)->response( 'images', $image->path, $image->original_filename, @@ -364,7 +372,7 @@ class ProductImageService Log::error('상품 이미지 스토리지에 없음', [ 'product_image_id' => $image->id, 'path' => $image->path, - 'disk' => $this->storage->getDisk(), + 'disk' => $image->disk ?: $this->storage->getDisk(), ]); return null; @@ -406,10 +414,11 @@ class ProductImageService $newStoredFilename = Str::uuid().'.'.$extension; $newPath = "products/{$targetProductCode}/{$newStoredFilename}"; - // 파일 복사 - $content = $this->storage->get('images', $source->path); + // 파일 복사 — 원본 행 disk 기준 (신규 행도 원본과 같은 disk 로 기록됨) + $sourceStorage = $this->storageForRow($source->disk); + $content = $sourceStorage->get('images', $source->path); if ($content) { - $this->storage->put('images', $newPath, $content); + $sourceStorage->put('images', $newPath, $content); } else { Log::warning('상품 이미지 복사 실패: 원본 파일 없음', [ 'hash' => $sourceHash, @@ -445,18 +454,42 @@ class ProductImageService * 실제 경로: storage/app/modules/sirsoft-ecommerce/images/products/{productCode}/ * * @param int $productId 상품 ID - * @return bool 삭제 성공 여부 + * @return bool 삭제 성공 여부 (대상 디스크 전체 기준) */ public function deleteByProductId(int $productId): bool { $product = $this->productRepository->find($productId); - if ($product) { - return $this->storage->deleteDirectory('images', "products/{$product->product_code}"); + // 행이 여러 disk 에 걸칠 수 있어(디스크 전환 후 혼재) disk 별로 디렉토리 삭제. + // 빈 disk(디스크 컬럼 도입 전 구 데이터)는 주입 스토리지 소속으로 정규화한다. + $rowDisks = $this->repository->getByProductId($productId) + ->pluck('disk') + ->map(fn ($disk) => ($disk === null || $disk === '') ? $this->storage->getDisk() : $disk) + ->unique(); + + $directory = $product + ? "products/{$product->product_code}" + // 폴백: product_id 기반 (기존 데이터 호환) + : "products/{$productId}"; + + if ($rowDisks->isEmpty()) { + // 행이 없으면 주입 스토리지의 잔여 디렉토리만 정리 (기존 동작 보존) + return $this->storage->deleteDirectory('images', $directory); } - // 폴백: product_id 기반 (기존 데이터 호환) - return $this->storage->deleteDirectory('images', "products/{$productId}"); + $results = []; + foreach ($rowDisks as $rowDisk) { + $results[] = $this->storageForRow($rowDisk)->deleteDirectory('images', $directory); + } + + // 행이 실리지 않은 주입 스토리지의 잔여 파일도 정리 시도하되 반환값에는 + // 반영하지 않는다 — 행 기준으로는 지울 것이 없는 디스크의 실패가 혼재 행 + // 삭제 성공을 false 로 오염시키면 안 된다 (반환값 = 행이 실린 디스크 전체 기준) + if (! $rowDisks->contains($this->storage->getDisk())) { + $this->storage->deleteDirectory('images', $directory); + } + + return ! in_array(false, $results, true); } /** diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewImageService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewImageService.php index 31420a2d..fdca7881 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewImageService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewImageService.php @@ -13,6 +13,7 @@ use Modules\Sirsoft\Ecommerce\Exceptions\ReviewImageUploadLimitException; use Modules\Sirsoft\Ecommerce\Models\ProductReview; use Modules\Sirsoft\Ecommerce\Models\ProductReviewImage; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewImageRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Services\Concerns\ResolvesRowStorage; use Symfony\Component\HttpFoundation\StreamedResponse; /** @@ -22,6 +23,8 @@ use Symfony\Component\HttpFoundation\StreamedResponse; */ class ProductReviewImageService { + use ResolvesRowStorage; + /** * ProductReviewImageService 생성자 * @@ -48,7 +51,8 @@ class ProductReviewImageService { $maxImages = (int) $this->settingsService->getSetting( 'review_settings.max_images', - config('ecommerce.review.max_images', 5) + // 모듈 config 는 'sirsoft-ecommerce' 네임스페이스로 로드된다 (ModuleManager::loadModuleConfig) + config('sirsoft-ecommerce.review.max_images', 5) ); $currentCount = $review->images()->count(); @@ -121,8 +125,10 @@ class ProductReviewImageService { HookManager::doAction('sirsoft-ecommerce.review-image.before_delete', $image); - if ($this->storage->exists('images', $image->path)) { - $this->storage->delete('images', $image->path); + // 스토리지에서 파일 삭제 — 행 disk 기준 + $rowStorage = $this->storageForRow($image->disk); + if ($rowStorage->exists('images', $image->path)) { + $rowStorage->delete('images', $image->path); } $result = $this->repository->delete($image); @@ -159,7 +165,7 @@ class ProductReviewImageService return null; } - $response = $this->storage->response( + $response = $this->storageForRow($image->disk)->response( 'images', $image->path, $image->original_filename, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php index 2de49193..96fbd709 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductReviewService.php @@ -15,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Models\ProductReview; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderOptionRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewImageRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductReviewRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Services\Concerns\ResolvesRowStorage; use Modules\Sirsoft\Ecommerce\Support\ReviewWritePolicy; /** @@ -24,6 +25,8 @@ use Modules\Sirsoft\Ecommerce\Support\ReviewWritePolicy; */ class ProductReviewService { + use ResolvesRowStorage; + /** * ProductReviewService 생성자 * @@ -217,10 +220,11 @@ class ProductReviewService HookManager::doAction('sirsoft-ecommerce.product-review.before_delete', $review); return DB::transaction(function () use ($review) { - // 이미지 파일 삭제 (StorageInterface 사용) + // 이미지 파일 삭제 (StorageInterface 사용) — 행 disk 기준 foreach ($review->images as $image) { - if ($this->storage->exists('images', $image->path)) { - $this->storage->delete('images', $image->path); + $rowStorage = $this->storageForRow($image->disk); + if ($rowStorage->exists('images', $image->path)) { + $rowStorage->delete('images', $image->path); } } @@ -266,11 +270,12 @@ class ProductReviewService HookManager::doAction('sirsoft-ecommerce.product-review.before_bulk_delete', $ids, $reviews); return DB::transaction(function () use ($reviews, $ids, $snapshots) { - // 이미지 파일 일괄 삭제 + // 이미지 파일 일괄 삭제 — 행 disk 기준 foreach ($reviews as $review) { foreach ($review->images as $image) { - if ($this->storage->exists('images', $image->path)) { - $this->storage->delete('images', $image->path); + $rowStorage = $this->storageForRow($image->disk); + if ($rowStorage->exists('images', $image->path)) { + $rowStorage->delete('images', $image->path); } } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php index d41566f6..7d3716b5 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/ProductService.php @@ -1616,7 +1616,7 @@ class ProductService 'hash' => $img->hash, 'url' => $img->url, 'original_filename' => $img->original_filename, - 'download_url' => '/api/modules/sirsoft-ecommerce/product-image/'.$img->hash, + 'download_url' => $img->download_url, 'file_size' => $img->file_size, 'size' => $img->file_size, 'size_formatted' => $this->formatFileSize($img->file_size), diff --git a/modules/_bundled/sirsoft-ecommerce/src/Support/AssetStorage.php b/modules/_bundled/sirsoft-ecommerce/src/Support/AssetStorage.php new file mode 100644 index 00000000..111704c8 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/src/Support/AssetStorage.php @@ -0,0 +1,43 @@ + + */ + private static array $drivers = []; + + /** + * 지정 디스크의 ecommerce 모듈 스토리지 드라이버를 반환합니다. + * + * @param string $disk 디스크 이름 (자산 행의 disk 컬럼 값) + * @return StorageInterface 해당 디스크의 스토리지 드라이버 + */ + public static function forDisk(string $disk): StorageInterface + { + return self::$drivers[$disk] ??= new ModuleStorageDriver('sirsoft-ecommerce', $disk); + } + + /** + * memoize 캐시를 비웁니다 (테스트 격리용). + */ + public static function flush(): void + { + self::$drivers = []; + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index 85340336..5899b726 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -1327,8 +1327,14 @@ return [ ], ], + // Public asset disk (validated against the core catalog) + 'public_asset_disk_invalid' => 'Please select a valid public asset disk.', + // Field name translations (Laravel standard) 'attributes' => [ + // Public asset storage + 'basic_info.public_asset_disk' => 'Public asset disk', + // Shipping policy country settings 'country_settings' => 'Country settings', 'country_settings.*.country_code' => 'Country', diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 5052798b..d016c512 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -1327,8 +1327,14 @@ return [ ], ], + // 공개 자산 디스크 (코어 카탈로그 조회 검증) + 'public_asset_disk_invalid' => '올바른 공개 자산 디스크를 선택해주세요.', + // 필드명 한국어 변환 (Laravel 표준) 'attributes' => [ + // 공개 자산 스토리지 + 'basic_info.public_asset_disk' => '공개 자산 디스크', + // 배송정책 국가별 설정 'country_settings' => '국가별 설정', 'country_settings.*.country_code' => '국가', diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/public-asset-disk-override.spec.ts b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/public-asset-disk-override.spec.ts new file mode 100644 index 00000000..69b47c5e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/public-asset-disk-override.spec.ts @@ -0,0 +1,136 @@ +/** + * E2E: 이커머스 공개 자산 디스크 오버라이드 필드 (공개#100) + * + * 시나리오 매니페스트: `tests/scenarios/public-asset-cdn.yaml` + * (케이스 마킹은 각 test 의 라인 주석에 있다 — 축 구분자는 쉼표) + * + * 기본정보 탭의 "공개 자산 디스크" Select 가 [코어 설정 따름('')] + 코어 카탈로그를 + * 그리는지, 그리고 선택→저장→재진입 왕복에서 값이 유지되는지 검증한다. + * 코어 드라이버 탭 카드는 admin_basic 템플릿 spec 이 담당한다 — 여기서는 모듈이 + * 소유한 오버라이드 표면만 본다. + * + * composite Select 는 네이티브 select 가 아니라 커스텀 드롭다운(root div 에 name 속성, + * Button 토글, portal listbox role=option)이다 — 상호작용은 그 구조를 따른다. + * 저장 후에는 반드시 원값으로 되돌려 사이트 설정을 바꾸지 않는다. + * + * @effects settings_catalog_includes_plugin_registered_disks, + * none_override_forces_streaming_over_global + */ +import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth'; +import type { Page } from '@playwright/test'; + +const ECOMMERCE_BASIC_TAB = '/admin/ecommerce/settings?tab=basic_info'; +const MODULE_SELECT_ROOT = '[name="basic_info.public_asset_disk"]'; + +/** + * 기본정보 탭 진입 — 카탈로그 적재를 엔진 상태로 확인한다. + * + * DOM attach 만 기다리면 바인딩 전 빈 Select 를 카탈로그 누락으로 오인한다. + * + * @param page Playwright 페이지 + */ +async function gotoBasicInfoTab(page: Page): Promise { + await page.goto(ECOMMERCE_BASIC_TAB); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect + .poll( + async () => + await page.evaluate(() => { + const local = (window as any).G7Core?.state?.getLocal?.(); + return Array.isArray(local?.form?.available_public_asset_disks); + }), + { timeout: 20_000 }, + ) + .toBe(true); +} + +/** + * 커스텀 드롭다운을 열고 옵션 라벨 목록을 읽는다 (읽은 뒤 드롭다운은 닫지 않음). + * + * @param page Playwright 페이지 + * @returns 옵션 라벨 배열 + */ +async function openAndReadOptions(page: Page): Promise { + await page.locator(`${MODULE_SELECT_ROOT} button`).first().click(); + const listbox = page.locator('[role="listbox"]'); + await expect(listbox).toBeVisible({ timeout: 10_000 }); + + return listbox.locator('[role="option"]').allInnerTexts(); +} + +/** + * 엔진 로컬 상태에서 오버라이드 필드 값을 읽는다. + * + * @param page Playwright 페이지 + * @returns 현재 폼 값 (미설정이면 '') + */ +async function readOverrideValue(page: Page): Promise { + return page.evaluate(() => { + const form = (window as any).G7Core?.state?.getLocal?.()?.form; + return form?.basic_info?.public_asset_disk ?? ''; + }); +} + +/** + * 드롭다운에서 라벨로 옵션을 고른다. + * + * @param page Playwright 페이지 + * @param label 고를 옵션 라벨(부분 일치) + */ +async function selectOptionByLabel(page: Page, label: string): Promise { + await openAndReadOptions(page); + await page.locator('[role="listbox"] [role="option"]', { hasText: label }).first().click(); +} + +/** 저장 버튼을 눌러 성공 토스트를 기다린다. */ +async function save(page: Page): Promise { + await page.locator('#save_button button, button#save_button, [data-testid="settings-save-button"]').first().click(); + await expect(page.getByText('설정이 저장되었습니다')).toBeVisible({ timeout: 20_000 }); +} + +test.describe('이커머스 공개 자산 디스크 오버라이드', () => { + // @scenario consumer=product, disk_setting=none, e2e=ecommerce_settings_field, hook=unregistered, override=follow_core, row_state=legacy_local_row + // @effects settings_catalog_includes_plugin_registered_disks + test('코어 따름 기본 옵션 + 코어 카탈로그를 그린다', async ({ page, settingsToken }) => { + await authenticatePage(page, settingsToken); + await gotoBasicInfoTab(page); + + await expect(page.locator(MODULE_SELECT_ROOT)).toBeVisible({ timeout: 20_000 }); + + const labels = await openAndReadOptions(page); + // 첫 옵션 = 코어 설정 따름('') + 카탈로그(none 포함 — 강제 스트리밍 오버라이드) + expect(labels[0]).toContain('코어 설정 따름'); + expect(labels.join('|')).toContain('사용 안 함'); + expect(labels.join('|')).toContain('Amazon S3'); + + await page.keyboard.press('Escape'); + }); + + // @scenario consumer=product, disk_setting=public, e2e=save_roundtrip, hook=unregistered, override=module_override, row_state=new_remote_row + // @effects none_override_forces_streaming_over_global + test('선택-저장-재진입 왕복에서 오버라이드 값이 유지되고 원복된다', async ({ page, settingsToken }) => { + test.setTimeout(150_000); + await authenticatePage(page, settingsToken); + await gotoBasicInfoTab(page); + + const original = (await readOverrideValue(page)) as string; + + // 모듈 개별 오버라이드로 Public 디스크 지정 → 저장 + await selectOptionByLabel(page, 'Public'); + await expect.poll(() => readOverrideValue(page)).toBe('public'); + await save(page); + + // 재진입 — 서버 저장값이 폼으로 복원되는지 (저장 응답의 카탈로그 재부착 포함) + await gotoBasicInfoTab(page); + await expect.poll(() => readOverrideValue(page), { timeout: 20_000 }).toBe('public'); + + // 원복 (사이트 설정 보존) + const restoreLabel = + original === 'public' ? 'Public' : original === 's3' ? 'Amazon S3' : original === 'none' ? '사용 안 함' : '코어 설정 따름'; + await selectOptionByLabel(page, restoreLabel); + await save(page); + + await gotoBasicInfoTab(page); + await expect.poll(() => readOverrideValue(page), { timeout: 20_000 }).toBe(original || ''); + }); +}); diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Requests/StoreEcommerceSettingsRequestPublicAssetDiskTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Requests/StoreEcommerceSettingsRequestPublicAssetDiskTest.php new file mode 100644 index 00000000..880f2183 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Requests/StoreEcommerceSettingsRequestPublicAssetDiskTest.php @@ -0,0 +1,113 @@ + 'basic_info', + 'basic_info' => [ + // 같은 탭의 필수 형제 필드 — 이 축과 무관하지만 없으면 탭 전체가 422 라 + // 디스크 규칙만 분리해 보기 위해 채운다. + 'shop_name' => '테스트 상점', + 'route_path' => 'shop', + 'public_asset_disk' => $disk, + ], + ]); + $request->setContainer($this->app)->setRedirector($this->app['redirect']); + $request->validateResolved(); + + return $request; + } + + public function test_core_catalog_disk_is_accepted(): void + { + $request = $this->resolve('public'); + + $this->assertSame('public', $request->validated()['basic_info']['public_asset_disk']); + } + + public function test_empty_value_is_accepted_as_follow_core(): void + { + $request = $this->resolve(''); + + // 빈 값 = "코어 설정 따름" 이므로 검증을 통과해야 한다. + $this->assertSame('', $request->validated()['basic_info']['public_asset_disk'] ?? ''); + } + + public function test_unknown_disk_is_rejected(): void + { + $this->expectException(ValidationException::class); + + try { + $this->resolve('nonexistent_disk'); + } catch (ValidationException $e) { + $this->assertArrayHasKey( + 'basic_info.public_asset_disk', + $e->validator->errors()->toArray(), + '카탈로그에 없는 디스크는 basic_info.public_asset_disk 오류를 내야 한다' + ); + + throw $e; + } + } + + public function test_plugin_registered_disk_is_accepted(): void + { + // 플러그인이 훅으로 등록한 디스크도 통과해야 한다 — 정적 in 이었다면 여기서 깨진다. + Config::set('filesystems.disks.plugin_cdn', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/plugin_cdn'), + 'url' => 'https://plugin-cdn.test/assets', + ]); + + HookManager::addFilter( + 'core.settings.available_public_asset_drivers', + function (array $drivers): array { + $drivers[] = [ + 'id' => 'plugin_cdn', + 'label' => ['ko' => '플러그인 CDN', 'en' => 'Plugin CDN'], + 'provider' => 'test-plugin', + ]; + + return $drivers; + } + ); + + $request = $this->resolve('plugin_cdn'); + + $this->assertSame('plugin_cdn', $request->validated()['basic_info']['public_asset_disk']); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/HasDirectAssetUrlTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/HasDirectAssetUrlTest.php new file mode 100644 index 00000000..9372b0d9 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/HasDirectAssetUrlTest.php @@ -0,0 +1,152 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + AssetStorage::flush(); + } + + protected function tearDown(): void + { + AssetStorage::flush(); + HookManager::resetAll(); + + parent::tearDown(); + } + + #[Test] + public function product_image_falls_back_to_api_url_for_local_disk_row(): void + { + $image = new ProductImage([ + 'hash' => 'aaaabbbbcccc', + 'disk' => 'modules', + 'path' => 'products/P001/a.jpg', + ]); + + $this->assertSame( + '/api/modules/sirsoft-ecommerce/product-image/aaaabbbbcccc', + $image->download_url + ); + } + + #[Test] + public function product_image_returns_direct_url_for_cdn_disk_row(): void + { + $image = new ProductImage([ + 'hash' => 'aaaabbbbcccc', + 'disk' => 'fake_cdn', + 'path' => 'products/P001/a.jpg', + ]); + + $this->assertSame( + 'https://cdn.test/assets/sirsoft-ecommerce/images/products/P001/a.jpg', + $image->download_url + ); + } + + /** + * @scenario consumer=category, disk_setting=public, e2e=ecommerce_settings_field, hook=supply, override=module_override, row_state=new_remote_row + * + * @effects download_url_falls_back_to_api_path_when_direct_unavailable + */ + #[Test] + public function category_and_review_images_resolve_direct_url_and_fallback(): void + { + $cdnCategory = new CategoryImage([ + 'hash' => 'ccc111222333', + 'disk' => 'fake_cdn', + 'path' => 'category/2026/01/01/c.jpg', + ]); + $localReview = new ProductReviewImage([ + 'hash' => 'rrr111222333', + 'disk' => 'modules', + 'path' => 'reviews/1/r.jpg', + ]); + + $this->assertSame( + 'https://cdn.test/assets/sirsoft-ecommerce/images/category/2026/01/01/c.jpg', + $cdnCategory->download_url + ); + $this->assertSame( + '/api/modules/sirsoft-ecommerce/review-image/rrr111222333', + $localReview->download_url + ); + } + + /** + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function mixed_rows_resolve_urls_per_row_disk(): void + { + // 혼재 운용 — 디스크 전환 이전 로컬 행과 이후 원격 행이 각자 disk 기준으로 해석 + $localRow = new ProductImage(['hash' => 'llllllllllll', 'disk' => 'modules', 'path' => 'products/P001/old.jpg']); + $remoteRow = new ProductImage(['hash' => 'rrrrrrrrrrrr', 'disk' => 'fake_cdn', 'path' => 'products/P001/new.jpg']); + + $this->assertStringStartsWith('/api/modules/sirsoft-ecommerce/product-image/', $localRow->download_url); + $this->assertStringStartsWith('https://cdn.test/assets/', $remoteRow->download_url); + } + + /** + * @scenario consumer=review, disk_setting=s3_without_url, e2e=ecommerce_settings_field, hook=unregistered, override=follow_core, row_state=new_remote_row + * + * @effects download_url_falls_back_to_api_path_when_direct_unavailable + */ + #[Test] + public function blank_disk_or_path_falls_back_to_api_url(): void + { + $noDisk = new ProductImage(['hash' => 'aaaabbbbcccc', 'disk' => '', 'path' => 'products/P001/a.jpg']); + $noPath = new ProductImage(['hash' => 'aaaabbbbcccc', 'disk' => 'fake_cdn', 'path' => '']); + + $this->assertSame('/api/modules/sirsoft-ecommerce/product-image/aaaabbbbcccc', $noDisk->download_url); + $this->assertSame('/api/modules/sirsoft-ecommerce/product-image/aaaabbbbcccc', $noPath->download_url); + } + + /** + * @scenario consumer=category, disk_setting=none, e2e=ecommerce_settings_field, hook=block, override=follow_core, row_state=legacy_local_row + * + * @effects download_url_falls_back_to_api_path_when_direct_unavailable + */ + #[Test] + public function hook_block_falls_back_to_api_url(): void + { + HookManager::addFilter('core.storage.filter_url', fn ($url) => ''); + + $image = new ProductImage([ + 'hash' => 'aaaabbbbcccc', + 'disk' => 'fake_cdn', + 'path' => 'products/P001/a.jpg', + ]); + + $this->assertSame( + '/api/modules/sirsoft-ecommerce/product-image/aaaabbbbcccc', + $image->download_url + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryImageServiceRowDiskTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryImageServiceRowDiskTest.php new file mode 100644 index 00000000..e881149e --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryImageServiceRowDiskTest.php @@ -0,0 +1,189 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + AssetStorage::flush(); + + $this->service = app(CategoryImageService::class); + } + + protected function tearDown(): void + { + AssetStorage::flush(); + + parent::tearDown(); + } + + /** + * 지정 disk 에 파일과 카테고리 이미지 행을 함께 생성합니다. + * + * @param string $disk 행 disk + * @param string $path 이미지 경로 (images/ 하위 상대 경로) + * @return CategoryImage 생성된 행 + */ + private function makeRowOnDisk(string $disk, string $path): CategoryImage + { + (new ModuleStorageDriver('sirsoft-ecommerce', $disk))->put('images', $path, 'image-bytes'); + + return CategoryImage::create([ + 'temp_key' => 'category-row-disk-test', + 'original_filename' => 'category.jpg', + 'stored_filename' => basename($path), + 'disk' => $disk, + 'path' => $path, + 'mime_type' => 'image/jpeg', + 'file_size' => 11, + 'collection' => 'main', + 'sort_order' => 0, + ]); + } + + /** + * @effects serve_and_delete_follow_row_disk, mixed_rows_resolve_per_row_disk + */ + #[Test] + public function download_serves_row_from_its_recorded_disk(): void + { + // 혼재 운용 — 주입 스토리지 disk 와 다른 disk 의 행도 서빙 가능해야 한다 + $cdnRow = $this->makeRowOnDisk('fake_cdn', 'category/row-disk-test/cdn.jpg'); + $localRow = $this->makeRowOnDisk('modules', 'category/row-disk-test/local.jpg'); + + $this->assertInstanceOf(StreamedResponse::class, $this->service->download($cdnRow->hash)); + $this->assertInstanceOf(StreamedResponse::class, $this->service->download($localRow->hash)); + } + + /** + * @effects serve_and_delete_follow_row_disk + */ + #[Test] + public function delete_removes_file_on_row_disk(): void + { + $cdnRow = $this->makeRowOnDisk('fake_cdn', 'category/row-disk-test/del.jpg'); + $cdnDriver = new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'); + $this->assertTrue($cdnDriver->exists('images', $cdnRow->path)); + + $this->assertTrue($this->service->delete($cdnRow->id)); + + $this->assertFalse($cdnDriver->exists('images', $cdnRow->path)); + } + + /** + * 행 disk 를 제공하던 플러그인이 비활성화되어 config 에서 사라진 상태를 만듭니다. + * + * @param string $disk 사라지게 할 디스크명 + */ + private function vanishDisk(string $disk): void + { + Config::set("filesystems.disks.{$disk}", null); + Storage::forgetDisk($disk); + AssetStorage::flush(); + } + + /** + * 고아 disk 행의 서빙은 500 이 아니라 스트리밍 폴백이어야 한다. + * + * 디스크를 제공하던 플러그인을 비활성화하면 행 disk 가 config 에서 사라진다. + * 이때 withDisk 로 미등록 디스크를 만들면 Laravel 이 InvalidArgumentException 을 + * 던져 서빙이 500 이 된다 — 접근자는 정상적으로 API 경로를 내주므로 사용자에게는 + * 이미지 링크가 살아 있는데 열면 500 인 상태가 된다. + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function download_falls_back_when_row_disk_is_orphaned(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'category/row-disk-test/orphan.jpg'); + $this->vanishDisk('fake_cdn'); + + // 예외 없이 폴백 — 파일은 도달 불가이므로 null(404) 이 정상 degradation + $this->assertNull($this->service->download($row->hash)); + } + + /** + * 고아 disk 행의 삭제도 예외 없이 완료되어야 한다(카테고리 삭제 자체가 막히면 안 됨). + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function delete_succeeds_when_row_disk_is_orphaned(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'category/row-disk-test/orphan-del.jpg'); + $this->vanishDisk('fake_cdn'); + + $this->assertTrue($this->service->delete($row->id)); + $this->assertNull(CategoryImage::find($row->id)); + } + + /** + * put 종단 — SP 카테고리 배선을 실제로 경유해 공개 자산 디스크 설정값이 + * 신규 행의 disk 컬럼에 기록되는지 단언합니다(배선 회귀 검출). + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_configured_public_asset_disk_on_row(): void + { + Config::set('core.storage.public_asset_disk', 'fake_cdn'); + + $service = app(CategoryImageService::class); + $image = $service->upload(UploadedFile::fake()->image('cdn-category.jpg', 40, 40)); + + $this->assertSame('fake_cdn', $image->disk); + $this->assertTrue( + (new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'))->exists('images', $image->path) + ); + + $this->assertTrue($service->delete($image->id)); + } + + /** + * put 종단 — 공개 자산 디스크 미설정이면 신규 행이 기본 디스크로 기록(현행 보존). + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_base_disk_when_public_asset_disk_unset(): void + { + Config::set('core.storage.public_asset_disk', ''); + + $service = app(CategoryImageService::class); + $image = $service->upload(UploadedFile::fake()->image('local-category.jpg', 40, 40)); + + $this->assertSame('modules', $image->disk); + + $this->assertTrue($service->delete($image->id)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceRowDiskTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceRowDiskTest.php new file mode 100644 index 00000000..eac2e2de --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceRowDiskTest.php @@ -0,0 +1,285 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + AssetStorage::flush(); + + $this->service = app(ProductImageService::class); + } + + protected function tearDown(): void + { + AssetStorage::flush(); + + parent::tearDown(); + } + + /** + * 지정 disk 에 파일과 이미지 행을 함께 생성합니다. + * + * @param string $disk 행 disk + * @param string $path 이미지 경로 + * @return ProductImage 생성된 행 + */ + private function makeRowOnDisk(string $disk, string $path): ProductImage + { + (new ModuleStorageDriver('sirsoft-ecommerce', $disk))->put('images', $path, 'image-bytes'); + + return ProductImage::create([ + 'temp_key' => 'row-disk-test', + 'original_filename' => 'a.jpg', + 'stored_filename' => basename($path), + 'disk' => $disk, + 'path' => $path, + 'mime_type' => 'image/jpeg', + 'file_size' => 11, + 'collection' => 'main', + 'is_thumbnail' => false, + 'sort_order' => 0, + ]); + } + + /** + * @effects serve_and_delete_follow_row_disk, mixed_rows_resolve_per_row_disk + */ + #[Test] + public function download_serves_row_from_its_recorded_disk(): void + { + // 혼재 운용 — 주입 스토리지 disk 와 다른 disk 의 행도 서빙 가능해야 한다 + $cdnRow = $this->makeRowOnDisk('fake_cdn', 'products/temp/row-disk-test/cdn.jpg'); + $localRow = $this->makeRowOnDisk('modules', 'products/temp/row-disk-test/local.jpg'); + + $this->assertInstanceOf(StreamedResponse::class, $this->service->download($cdnRow->hash)); + $this->assertInstanceOf(StreamedResponse::class, $this->service->download($localRow->hash)); + } + + /** + * @effects serve_and_delete_follow_row_disk + */ + #[Test] + public function delete_removes_file_on_row_disk(): void + { + $cdnRow = $this->makeRowOnDisk('fake_cdn', 'products/temp/row-disk-test/del.jpg'); + $cdnDriver = new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'); + $this->assertTrue($cdnDriver->exists('images', $cdnRow->path)); + + $this->assertTrue($this->service->delete($cdnRow->id)); + + $this->assertFalse($cdnDriver->exists('images', $cdnRow->path)); + } + + /** + * deleteByProductId 반환값 — 행이 CDN 디스크에만 있어도 행 disk 삭제 결과를 + * 반환해야 한다 (기본 디스크의 빈 디렉토리 결과로 오염 금지). + * + * @effects serve_and_delete_follow_row_disk + */ + #[Test] + public function delete_by_product_id_returns_row_disk_result_for_cdn_only_rows(): void + { + $product = Product::create([ + 'product_code' => 'ROWDEL-'.Str::random(6), + 'name' => ['ko' => '행 disk 삭제 테스트'], + 'selling_price' => 1000, + 'stock_quantity' => 1, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + ]); + + $path = "products/{$product->product_code}/cdn-only.jpg"; + $cdnDriver = new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'); + $cdnDriver->put('images', $path, 'image-bytes'); + ProductImage::create([ + 'product_id' => $product->id, + 'original_filename' => 'cdn-only.jpg', + 'stored_filename' => 'cdn-only.jpg', + 'disk' => 'fake_cdn', + 'path' => $path, + 'mime_type' => 'image/jpeg', + 'file_size' => 11, + 'collection' => 'main', + 'is_thumbnail' => false, + 'sort_order' => 0, + ]); + + $this->assertTrue($this->service->deleteByProductId($product->id)); + $this->assertFalse($cdnDriver->exists('images', $path)); + } + + /** + * deleteByProductId — disk 컬럼이 빈 구 데이터 행은 주입 스토리지 소속으로 + * 정규화되어 함께 삭제된다. + * + * @effects serve_and_delete_follow_row_disk + */ + #[Test] + public function delete_by_product_id_normalizes_blank_disk_rows_to_base_storage(): void + { + $product = Product::create([ + 'product_code' => 'ROWDEL-'.Str::random(6), + 'name' => ['ko' => '구 데이터 삭제 테스트'], + 'selling_price' => 1000, + 'stock_quantity' => 1, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + ]); + + $path = "products/{$product->product_code}/legacy.jpg"; + $baseDriver = new ModuleStorageDriver('sirsoft-ecommerce', 'modules'); + $baseDriver->put('images', $path, 'image-bytes'); + ProductImage::create([ + 'product_id' => $product->id, + 'original_filename' => 'legacy.jpg', + 'stored_filename' => 'legacy.jpg', + 'disk' => '', + 'path' => $path, + 'mime_type' => 'image/jpeg', + 'file_size' => 11, + 'collection' => 'main', + 'is_thumbnail' => false, + 'sort_order' => 0, + ]); + + $this->assertTrue($this->service->deleteByProductId($product->id)); + $this->assertFalse($baseDriver->exists('images', $path)); + } + + /** + * put 종단 — SP 카테고리 배선(storageCategoryServices)을 실제로 경유해 + * 공개 자산 디스크 설정값이 신규 행의 disk 컬럼에 기록되는지 단언합니다. + * (픽스처 disk 직접 주입이 아니라 실제 업로드 경로 — 배선 회귀 검출) + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_configured_public_asset_disk_on_row(): void + { + Config::set('core.storage.public_asset_disk', 'fake_cdn'); + + // 컨테이너에서 새로 해석 — contextual binding 이 getStorageFor('images') 를 평가 + $service = app(ProductImageService::class); + + $image = $service->upload( + UploadedFile::fake()->image('cdn-upload.jpg', 40, 40) + ); + + $this->assertSame('fake_cdn', $image->disk); + $this->assertTrue( + (new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'))->exists('images', $image->path) + ); + + // 뒷정리 — 업로드 파일 제거 (행 disk 기준 삭제) + $this->assertTrue($service->delete($image->id)); + } + + /** + * put 종단 — 공개 자산 디스크 미설정이면 신규 행이 기본 디스크로 기록 (현행 보존) + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_base_disk_when_public_asset_disk_unset(): void + { + Config::set('core.storage.public_asset_disk', ''); + + $service = app(ProductImageService::class); + + $image = $service->upload( + UploadedFile::fake()->image('local-upload.jpg', 40, 40) + ); + + $this->assertSame('modules', $image->disk); + + $this->assertTrue($service->delete($image->id)); + } + + /** + * 행 disk 를 제공하던 플러그인이 비활성화되어 config 에서 사라진 상태를 만듭니다. + * + * @param string $disk 사라지게 할 디스크명 + */ + private function vanishDisk(string $disk): void + { + Config::set("filesystems.disks.{$disk}", null); + Storage::forgetDisk($disk); + AssetStorage::flush(); + } + + /** + * 고아 disk 행의 서빙은 500 이 아니라 스트리밍 폴백이어야 한다. + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function download_falls_back_when_row_disk_is_orphaned(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'products/row-disk-test/orphan.jpg'); + $this->vanishDisk('fake_cdn'); + + $this->assertNull($this->service->download($row->hash)); + } + + /** + * 고아 disk 행의 삭제도 예외 없이 완료되어야 한다. + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function delete_succeeds_when_row_disk_is_orphaned(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'products/row-disk-test/orphan-del.jpg'); + $this->vanishDisk('fake_cdn'); + + $this->assertTrue($this->service->delete($row->id)); + $this->assertNull(ProductImage::find($row->id)); + } + + /** + * 상품 삭제(이미지 일괄 삭제)도 고아 disk 행 때문에 막히면 안 된다. + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function delete_by_product_id_succeeds_when_row_disk_is_orphaned(): void + { + $product = Product::factory()->create(); + $row = $this->makeRowOnDisk('fake_cdn', 'products/row-disk-test/orphan-bulk.jpg'); + $row->update(['product_id' => $product->id, 'temp_key' => null]); + + $this->vanishDisk('fake_cdn'); + + $this->assertTrue($this->service->deleteByProductId($product->id)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceTest.php index 080f86d7..bb6c132c 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceTest.php @@ -434,6 +434,11 @@ class ProductImageServiceTest extends ModuleTestCase ->with($sourceHash) ->andReturn($sourceImage); + // 복사는 원본 행 disk 기준으로 스토리지를 해석한다 (혼재 운용, 공개#100) + $this->storage + ->shouldReceive('getDisk') + ->andReturn('local'); + $this->storage ->shouldReceive('get') ->once() diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceRowDiskTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceRowDiskTest.php new file mode 100644 index 00000000..72757201 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceRowDiskTest.php @@ -0,0 +1,209 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + AssetStorage::flush(); + + Auth::login($this->createUser()); + + $product = Product::factory()->onSale()->create(); + $orderOption = OrderOption::factory()->create(['product_id' => $product->id]); + + $this->review = ProductReview::factory()->create([ + 'product_id' => $product->id, + 'order_option_id' => $orderOption->id, + 'user_id' => Auth::id(), + 'status' => ReviewStatus::VISIBLE->value, + ]); + + $this->service = app(ProductReviewImageService::class); + } + + protected function tearDown(): void + { + AssetStorage::flush(); + + parent::tearDown(); + } + + /** + * 지정 disk 에 파일과 리뷰 이미지 행을 함께 생성합니다. + * + * @param string $disk 행 disk + * @param string $path 이미지 경로 (images/ 하위 상대 경로) + * @return ProductReviewImage 생성된 행 + */ + private function makeRowOnDisk(string $disk, string $path): ProductReviewImage + { + (new ModuleStorageDriver('sirsoft-ecommerce', $disk))->put('images', $path, 'image-bytes'); + + return ProductReviewImage::create([ + 'review_id' => $this->review->id, + 'original_filename' => 'review.jpg', + 'stored_filename' => basename($path), + 'disk' => $disk, + 'path' => $path, + 'mime_type' => 'image/jpeg', + 'file_size' => 11, + 'collection' => 'main', + 'is_thumbnail' => false, + 'sort_order' => 0, + ]); + } + + /** + * @effects serve_and_delete_follow_row_disk, mixed_rows_resolve_per_row_disk + */ + #[Test] + public function download_serves_row_from_its_recorded_disk(): void + { + // 혼재 운용 — 주입 스토리지 disk 와 다른 disk 의 행도 서빙 가능해야 한다 + $cdnRow = $this->makeRowOnDisk('fake_cdn', 'reviews/row-disk-test/cdn.jpg'); + $localRow = $this->makeRowOnDisk('modules', 'reviews/row-disk-test/local.jpg'); + + $this->assertInstanceOf(StreamedResponse::class, $this->service->download($cdnRow->hash)); + $this->assertInstanceOf(StreamedResponse::class, $this->service->download($localRow->hash)); + } + + /** + * @effects serve_and_delete_follow_row_disk + */ + #[Test] + public function delete_removes_file_on_row_disk(): void + { + $cdnRow = $this->makeRowOnDisk('fake_cdn', 'reviews/row-disk-test/del.jpg'); + $cdnDriver = new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'); + $this->assertTrue($cdnDriver->exists('images', $cdnRow->path)); + + $this->assertTrue($this->service->delete($cdnRow)); + + $this->assertFalse($cdnDriver->exists('images', $cdnRow->path)); + } + + /** + * 행 disk 를 제공하던 플러그인이 비활성화되어 config 에서 사라진 상태를 만듭니다. + * + * @param string $disk 사라지게 할 디스크명 + */ + private function vanishDisk(string $disk): void + { + Config::set("filesystems.disks.{$disk}", null); + Storage::forgetDisk($disk); + AssetStorage::flush(); + } + + /** + * 고아 disk 행의 서빙은 500 이 아니라 스트리밍 폴백이어야 한다. + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function download_falls_back_when_row_disk_is_orphaned(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'reviews/row-disk-test/orphan.jpg'); + $this->vanishDisk('fake_cdn'); + + $this->assertNull($this->service->download($row->hash)); + } + + /** + * 고아 disk 행의 삭제도 예외 없이 완료되어야 한다(리뷰 삭제 자체가 막히면 안 됨). + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function delete_succeeds_when_row_disk_is_orphaned(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'reviews/row-disk-test/orphan-del.jpg'); + $this->vanishDisk('fake_cdn'); + + $this->assertTrue($this->service->delete($row)); + $this->assertNull(ProductReviewImage::find($row->id)); + } + + /** + * put 종단 — SP 카테고리 배선을 실제로 경유해 공개 자산 디스크 설정값이 + * 신규 행의 disk 컬럼에 기록되는지 단언합니다(배선 회귀 검출). + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_configured_public_asset_disk_on_row(): void + { + Config::set('core.storage.public_asset_disk', 'fake_cdn'); + + $service = app(ProductReviewImageService::class); + $image = $service->upload( + UploadedFile::fake()->image('cdn-review.jpg', 40, 40), + $this->review + ); + + $this->assertSame('fake_cdn', $image->disk); + $this->assertTrue( + (new ModuleStorageDriver('sirsoft-ecommerce', 'fake_cdn'))->exists('images', $image->path) + ); + + $this->assertTrue($service->delete($image)); + } + + /** + * put 종단 — 공개 자산 디스크 미설정이면 신규 행이 기본 디스크로 기록(현행 보존). + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_base_disk_when_public_asset_disk_unset(): void + { + Config::set('core.storage.public_asset_disk', ''); + + $service = app(ProductReviewImageService::class); + $image = $service->upload( + UploadedFile::fake()->image('local-review.jpg', 40, 40), + $this->review + ); + + $this->assertSame('modules', $image->disk); + + $this->assertTrue($service->delete($image)); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceTest.php index 4014ce5e..95cdcd4d 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceTest.php @@ -235,6 +235,11 @@ class ProductReviewImageServiceTest extends ModuleTestCase 'path' => 'reviews/1/test.jpg', ]); + // 삭제는 행 disk 기준으로 스토리지를 해석한다 (혼재 운용, 공개#100) + $this->storage + ->shouldReceive('getDisk') + ->andReturn('local'); + $this->storage ->shouldReceive('exists') ->with('images', 'reviews/1/test.jpg') @@ -261,6 +266,11 @@ class ProductReviewImageServiceTest extends ModuleTestCase 'path' => 'reviews/1/missing.jpg', ]); + // 삭제는 행 disk 기준으로 스토리지를 해석한다 (혼재 운용, 공개#100) + $this->storage + ->shouldReceive('getDisk') + ->andReturn('local'); + $this->storage ->shouldReceive('exists') ->with('images', 'reviews/1/missing.jpg') @@ -322,6 +332,11 @@ class ProductReviewImageServiceTest extends ModuleTestCase 'original_filename' => 'test.jpg', ]); + // 서빙은 행 disk 기준으로 스토리지를 해석한다 (혼재 운용, 공개#100) + $this->storage + ->shouldReceive('getDisk') + ->andReturn('local'); + $this->storage ->shouldReceive('response') ->once() diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDetailImageUrlTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDetailImageUrlTest.php new file mode 100644 index 00000000..16a90c25 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDetailImageUrlTest.php @@ -0,0 +1,97 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + AssetStorage::flush(); + } + + protected function tearDown(): void + { + AssetStorage::flush(); + + parent::tearDown(); + } + + /** + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function detail_form_serializes_download_url_per_row_disk(): void + { + $product = Product::create([ + 'product_code' => 'URLDELEG-'.Str::random(6), + 'name' => ['ko' => 'URL 위임 테스트 상품'], + 'selling_price' => 1000, + 'stock_quantity' => 1, + 'sales_status' => 'on_sale', + 'display_status' => 'visible', + ]); + + $localImage = ProductImage::create([ + 'product_id' => $product->id, + 'original_filename' => 'old.jpg', + 'stored_filename' => 'old.jpg', + 'disk' => 'modules', + 'path' => 'products/'.$product->id.'/old.jpg', + 'mime_type' => 'image/jpeg', + 'file_size' => 10, + 'collection' => 'main', + 'is_thumbnail' => true, + 'sort_order' => 0, + ]); + $cdnImage = ProductImage::create([ + 'product_id' => $product->id, + 'original_filename' => 'new.jpg', + 'stored_filename' => 'new.jpg', + 'disk' => 'fake_cdn', + 'path' => 'products/'.$product->id.'/new.jpg', + 'mime_type' => 'image/jpeg', + 'file_size' => 10, + 'collection' => 'main', + 'is_thumbnail' => false, + 'sort_order' => 1, + ]); + + $detail = app(ProductService::class)->getDetailForForm($product->id); + + $this->assertNotNull($detail); + $byHash = collect($detail['images'])->keyBy('hash'); + + // 혼재 운용 — 구 로컬 행은 API 폴백, 신규 원격 행은 직접 URL 로 직렬화 + $this->assertSame( + '/api/modules/sirsoft-ecommerce/product-image/'.$localImage->hash, + $byHash[$localImage->hash]['download_url'], + ); + $this->assertSame( + 'https://cdn.test/assets/sirsoft-ecommerce/images/products/'.$product->id.'/new.jpg', + $byHash[$cdnImage->hash]['download_url'], + ); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/StorageDiskRecursionTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/StorageDiskRecursionTest.php new file mode 100644 index 00000000..2c89671b --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/StorageDiskRecursionTest.php @@ -0,0 +1,126 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + Config::set('core.storage.public_asset_disk', ''); + + $this->module = app(ModuleManager::class)->getModule('sirsoft-ecommerce') + ?? new Module(base_path('modules/sirsoft-ecommerce')); + + // 실제 설정 로드처럼 get() 내부에서 모듈 스토리지('settings')를 재요구하는 스파이. + // 재진입(= getStorageDiskFor('settings') 가 다시 설정을 조회하는 회귀) 발생 시 + // reentered 플래그 + 호출 횟수 증가로 검출됩니다. + $this->settingsSpy = new class extends ModuleSettingsService + { + public ?Module $module = null; + + public int $calls = 0; + + public bool $reentered = false; + + public ?string $nestedDisk = null; + + private bool $inGet = false; + + public function __construct() {} + + public function get(string $identifier, ?string $key = null, mixed $default = null): mixed + { + $this->calls++; + + if ($this->inGet) { + // 설정 로드 중 설정 재조회 = 순환 고리. 무한 재귀 대신 폭로. + $this->reentered = true; + + return $default; + } + + $this->inGet = true; + + try { + // 설정 로드가 스토리지를 경유하는 실제 경로 재현 — + // 이 호출이 getStorageDiskFor('settings') 를 타고 되돌아온다. + $this->nestedDisk = $this->module->getStorageFor('settings')->getDisk(); + + if ($key === 'basic_info.public_asset_disk') { + return 'fake_cdn'; + } + + return $default; + } finally { + $this->inGet = false; + } + } + }; + $this->settingsSpy->module = $this->module; + $this->app->instance(ModuleSettingsService::class, $this->settingsSpy); + } + + protected function tearDown(): void + { + $this->app->forgetInstance(ModuleSettingsService::class); + + parent::tearDown(); + } + + /** + * @effects settings_load_recursion_free + */ + #[Test] + public function settings_category_never_consults_module_settings(): void + { + // 'settings' 카테고리 해석은 설정 서비스를 한 번도 거치지 않아야 한다 — + // 거치는 순간 설정 로드 ↔ 디스크 해석의 순환 고리가 열린다. + $this->assertSame($this->module->getStorageDisk(), $this->module->getStorageDiskFor('settings')); + $this->assertSame(0, $this->settingsSpy->calls); + } + + /** + * @effects settings_load_recursion_free + */ + #[Test] + public function images_resolution_survives_settings_load_that_uses_storage(): void + { + // 공개 자산 카테고리 해석 → 설정 조회 → (설정 로드가 'settings' 스토리지 재요구) + // 전체 사슬이 유한 종료하고, 설정 조회는 정확히 1회만 발생해야 한다. + $this->assertSame('fake_cdn', $this->module->getStorageDiskFor('images')); + + $this->assertSame(1, $this->settingsSpy->calls); + $this->assertFalse($this->settingsSpy->reentered, '설정 로드 중 설정 재조회(순환) 발생'); + // 설정 로드가 요구한 'settings' 스토리지는 공개 자산 디스크가 아닌 기본 디스크 + $this->assertSame($this->module->getStorageDisk(), $this->settingsSpy->nestedDisk); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md index 95f81e2c..d6f5e49a 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md +++ b/plugins/_bundled/sirsoft-ckeditor5/CHANGELOG.md @@ -9,6 +9,11 @@ ### Added - 에디터 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 업로드 이미지를 저장 전에 변환할 수 있습니다. +- 에디터에 올린 이미지를 코어의 공개 자산 스토리지(S3+CDN 등) 디스크에 저장하고 본문에는 CDN 직접 주소로 서빙할 수 있습니다. 에디터 설정의 「공개 자산 디스크」에서 이 플러그인만 다른 디스크를 쓰도록 개별 설정할 수도 있습니다(비우면 코어 설정 따름). 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.) + +### Changed + +- 코어 최소 요구 버전을 7.0.7 로 상향했습니다. ## [1.0.1] - 2026-08-10 diff --git a/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json b/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json index 658366f4..ab8ef4c2 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json +++ b/plugins/_bundled/sirsoft-ckeditor5/config/settings/defaults.json @@ -7,12 +7,14 @@ "imageUpload": true, "imageMaxSizeMb": 2, "editorHeight": 400, - "toolbar": "standard" + "toolbar": "standard", + "public_asset_disk": "" }, "frontend_schema": { "imageUpload": { "expose": true }, "imageMaxSizeMb": { "expose": true }, "editorHeight": { "expose": true }, - "toolbar": { "expose": true } + "toolbar": { "expose": true }, + "public_asset_disk": { "expose": false } } } \ No newline at end of file diff --git a/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md b/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md index 519eb643..fc177a37 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md +++ b/plugins/_bundled/sirsoft-ckeditor5/docs/api/upload.md @@ -52,7 +52,7 @@ _이 엔드포인트는 표준 envelope(`success`/`message`/`data`)를 사용하 | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| url | string | `/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6` | 업로드된 이미지의 서빙 URL. 모델 접근자 `download_url` 이 생성하며 `/api/plugins/sirsoft-ckeditor5/images/{hash}` (hash = 12자리 hex) 형식이다. CKEditor 가 이 값을 그대로 `` 에 삽입한다. | +| url | string | `/api/plugins/sirsoft-ckeditor5/images/a1b2c3d4e5f6` | 업로드된 이미지의 서빙 URL. 모델 접근자 `download_url` 이 생성한다. 기본(스트리밍)은 `/api/plugins/sirsoft-ckeditor5/images/{hash}` (hash = 12자리 hex) 형식이고, 공개 자산 디스크(플러그인 설정 `public_asset_disk` 또는 코어 전역 설정)가 URL 지원 디스크로 설정되어 있으면 그 디스크의 직접 URL(예: `https://cdn.example.com/sirsoft-ckeditor5/images/...`)이 반환된다. CKEditor 가 이 값을 그대로 `` 에 삽입한다. | 실패 시에는 최상위 `error.message` 만 반환합니다 (아래 에러 응답 표 참조). @@ -87,7 +87,7 @@ CKEditor5 의 SimpleUploadAdapter 가 에디터에 드롭/붙여넣은 이미지 이 규격은 우리 코드가 정한 것이 아니다 — 응답을 파싱하는 주체가 CDN 으로 로드되는 CKEditor5 43.3.1 의 `SimpleUploadAdapter`(`resources/js/handlers/initEditor.ts` 의 `editorConfig.simpleUpload`)이므로 파싱 규약을 바꿀 수 없다. 컨트롤러의 각 응답 지점에는 이 사유로 `audit:allow response-helper-bypass` 를 명시해 두었으니, envelope 로 감싸는 "수정" 을 하면 에디터의 이미지 업로드가 조용히 실패한다. - **요청 파라미터**: multipart body 의 `upload` 필드(이미지 파일 1개). 허용 MIME 은 `jpeg,jpg,png,gif,webp`, 최대 크기는 플러그인 설정 `imageMaxSizeMb`(기본 2MB) 로 동적 결정된다. 검증 실패도 CKEditor 규격(`{"error":{"message":...}}`, HTTP 422)으로 응답한다. - **선택 권한 게이트**: query 파라미터 `permission` 이 주어지면, 현재 사용자가 해당 권한을 갖지 못한 경우 403 `{"error":{"message":...}}`. 에디터를 임베드하는 화면이 업로드 권한을 세분화할 때 사용한다. -- 업로드 성공 시 반환하는 `url` 은 공개 서빙 엔드포인트(`GET /images/{hash}`)의 절대 URL 이다. +- 업로드 성공 시 반환하는 `url` 은 기본적으로 공개 서빙 엔드포인트(`GET /images/{hash}`)의 절대 URL 이다. 단, 공개 자산 디스크(플러그인 설정 `public_asset_disk` > 코어 전역 `drivers.public_asset_disk`)가 직접 URL 을 지원하는 디스크(public / URL 이 설정된 s3 등)로 지정되어 있으면 스트리밍 경로 대신 그 디스크의 직접 URL(CDN)이 반환된다. 디스크 미설정('none' 포함)·URL 미지원 디스크·`core.storage.filter_url` 훅 차단 시에는 기존 스트리밍 경로로 폴백한다. **응답 예시** (성공 — CKEditor 규격, envelope 아님) diff --git a/plugins/_bundled/sirsoft-ckeditor5/plugin.json b/plugins/_bundled/sirsoft-ckeditor5/plugin.json index 0e051b9b..bf904f78 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/plugin.json +++ b/plugins/_bundled/sirsoft-ckeditor5/plugin.json @@ -11,7 +11,7 @@ "ko": "CKEditor 5를 이용한 WYSIWYG 에디터 플러그인입니다. 플러그인 설치만으로 기존 HtmlEditor가 교체됩니다.", "en": "WYSIWYG editor plugin using CKEditor 5. Installing this plugin replaces the default HtmlEditor." }, - "g7_version": ">=7.0.6", + "g7_version": ">=7.0.7", "dependencies": { "modules": {}, "plugins": {} diff --git a/plugins/_bundled/sirsoft-ckeditor5/plugin.php b/plugins/_bundled/sirsoft-ckeditor5/plugin.php index 71c4f2ae..d27dd7c3 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/plugin.php +++ b/plugins/_bundled/sirsoft-ckeditor5/plugin.php @@ -73,9 +73,62 @@ class Plugin extends AbstractPlugin ], 'required' => false, ], + // 선택지가 코어 카탈로그 + 플러그인 훅 등록 디스크로 동적이라 enum 불가 — string. + // 존재하지 않는 디스크 값은 resolvePublicAssetDisk() 가 스트리밍으로 안전 폴백한다. + 'public_asset_disk' => [ + 'type' => 'string', + 'max' => 100, + 'default' => '', + 'label' => [ + 'ko' => '공개 자산 디스크', + 'en' => 'Public Asset Disk', + ], + 'hint' => [ + 'ko' => '이 플러그인만 다른 디스크를 쓰려면 선택합니다. 비우면 코어 공개 자산 디스크 설정을 따릅니다.', + 'en' => 'Select to use a different disk for this plugin only. Leave empty to follow the core public asset disk setting.', + ], + 'required' => false, + ], ]; } + /** + * 완전 공개 자산 스토리지 카테고리 목록 + * + * 이 카테고리들만 공개 자산 디스크 설정을 따른다. 권한 검사가 걸린 자산은 + * 직접 URL 이 권한을 우회하므로 포함하지 않는다. 에디터가 이미지 외의 + * 공개 자산 업로드를 지원하게 되면 여기에만 카테고리를 추가하면 된다. + * + * @var list + */ + private const PUBLIC_ASSET_CATEGORIES = ['images']; + + /** + * 카테고리별 스토리지 디스크 이름 반환 + * + * 완전 공개 자산 카테고리만 공개 자산 디스크(플러그인 설정 public_asset_disk > + * 코어 전역 core.storage.public_asset_disk)를 따르고, 설정 등 나머지 카테고리는 + * 기본 디스크를 유지합니다. 미설정/고아 디스크는 기본 디스크로 폴백해 기존 + * 스트리밍 동작을 보존합니다. + * + * 플러그인 설정 조회는 공개 자산 카테고리에서만 수행합니다 — 'settings' 카테고리에서 + * 조회하면 설정 로드와 재귀 고리가 생깁니다 (AbstractPlugin 주석 참조). + * + * @param string $category 카테고리 + * @return string 디스크 이름 + */ + public function getStorageDiskFor(string $category): string + { + if (! in_array($category, self::PUBLIC_ASSET_CATEGORIES, true)) { + return $this->getStorageDisk(); + } + + $override = plugin_setting('sirsoft-ckeditor5', 'public_asset_disk', ''); + + return $this->resolvePublicAssetDisk(is_string($override) ? $override : '') + ?? $this->getStorageDisk(); + } + /** * 플러그인이 관리하는 동적 테이블 목록 반환 * diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPublicAssetDisk.test.tsx b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPublicAssetDisk.test.tsx new file mode 100644 index 00000000..495eac63 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPublicAssetDisk.test.tsx @@ -0,0 +1,248 @@ +/** + * CKEditor5 설정 화면 — 공개 자산 디스크 필드 테스트 (공개#100) + * + * @description + * 플러그인 오버라이드 필드는 저장 키(public_asset_disk)와 **플러그인 설정 응답에 + * 서버 부착되는** 카탈로그(available_public_asset_disks)를 바인딩해야 한다. + * 별도 코어 설정 API(/api/admin/settings, core.settings.read)를 교차 조회하면 + * 화면 권한(core.plugins.read)과 표면이 갈려 커스텀 역할에서 카탈로그가 조용히 + * 빈다 — 단일 표면 계약을 고정한다. 설정 스키마(getSettingsSchema)에 같은 키가 + * 없으면 저장 검증이 규칙을 만들지 않아 값이 조용히 버려진다 — 세 지점을 대조한다. + * + * 계약 단언에 더해, 실제 필드 서브트리를 createLayoutTest 로 렌더링해 + * 옵션 계산식($t 선두 + $localized 매핑)까지 검증한다. + * + * @effects settings_catalog_includes_plugin_registered_disks + */ + +import React from 'react'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +import pluginSettings from '../../../layouts/admin/plugin_settings.json'; + +// ─── 테스트용 컴포넌트 ─── + +const TestDiv: React.FC<{ className?: string; children?: React.ReactNode; 'data-testid'?: string }> = + ({ className, children, 'data-testid': testId }) => ( +
{children}
+ ); + +const TestLabel: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) => ; + +const TestP: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) =>

{children || text}

; + +const TestSelect: React.FC<{ + name?: string; value?: string; className?: string; options?: any[]; + onChange?: (e: React.ChangeEvent) => void; +}> = ({ name, value, className, options, onChange }) => ( + +); + +// 렌더러가 컴포넌트 목록을 Fragment 로 감싸므로 반드시 등록해야 한다. +const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}; + +function setupTestRegistry(): ComponentRegistry { + const registry = ComponentRegistry.getInstance(); + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } }, + P: { component: TestP, metadata: { name: 'P', type: 'basic' } }, + Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; + return registry; +} + +// ─── 다국어 (공개 자산 디스크 필드가 쓰는 키) ─── + +const translations = { + 'sirsoft-ckeditor5': { + settings: { + fields: { + public_asset_disk: { + label: '공개 자산 디스크', + hint: '에디터 이미지 서빙 디스크를 오버라이드합니다.', + follow_core: '코어 설정 따름', + }, + }, + }, + }, +}; + +/** + * 플러그인 루트(plugin.json 기준)를 위로 훑어 찾는다. + * + * @returns 플러그인 루트 절대경로 + */ +function pluginRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'plugin.json'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('plugin.json 을 가진 플러그인 루트를 찾지 못했습니다.'); +} + +/** + * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다 (slots 포함). + * + * @param node 탐색 시작 노드 + * @param predicate 노드 판정 함수 + * @returns 찾은 노드 또는 null + */ +function findNode(node: any, predicate: (n: any) => boolean): any { + if (!node || typeof node !== 'object') return null; + if (predicate(node)) return node; + + const childGroups = [node.children ?? [], ...(node.slots ? Object.values(node.slots) : [])]; + for (const group of childGroups) { + for (const child of group as any[]) { + const found = findNode(child, predicate); + if (found) return found; + } + } + + return null; +} + +// 실제 필드 서브트리를 그대로 렌더 대상으로 쓴다 — 사본 표현식이 아닌 배포 JSON 자체가 SSoT. +const publicAssetField = findNode( + pluginSettings, + (n) => n.id === 'field_public_asset_disk', +); + +const CATALOG = [ + { id: 'none', label: { ko: '사용 안 함 (스트리밍)', en: 'None (streaming)' } }, + { id: 'fake_cdn', label: { ko: '가짜 CDN', en: 'Fake CDN' } }, +]; + +describe('CKEditor5 공개 자산 디스크 필드 — 계약', () => { + it('코어 설정 API 교차 조회 데이터소스가 없다 (권한 표면 단일화)', () => { + const dataSources = (pluginSettings as any).data_sources; + + // 존재 앵커 먼저 — data_sources 가 비어 있으면 아래 부재 단언이 공허하게 통과한다 + expect(dataSources.find((d: any) => d.id === 'settings')).toBeDefined(); + + // 카탈로그는 플러그인 설정 응답에 서버 부착 — 화면 권한(core.plugins.read)과 + // 다른 권한(core.settings.read)을 요구하는 교차 조회가 남아 있으면 안 된다 + expect(dataSources.find((d: any) => d.endpoint === '/api/admin/settings')).toBeUndefined(); + }); + + it('Select 가 저장 키와 설정 응답 부착 카탈로그를 바인딩한다', () => { + const select = findNode( + publicAssetField, + (n) => n.name === 'Select' && n.props?.name === 'public_asset_disk', + ); + + expect(select).not.toBeNull(); + expect(select.props.options).toContain('_local.form?.available_public_asset_disks'); + expect(select.props.options).toContain('follow_core'); + }); + + it('설정 스키마(getSettingsSchema)가 같은 키를 선언한다 (저장 whitelist)', () => { + const pluginSource = fs.readFileSync(path.join(pluginRoot(), 'plugin.php'), 'utf-8'); + const defaults = JSON.parse( + fs.readFileSync(path.join(pluginRoot(), 'config/settings/defaults.json'), 'utf-8'), + ); + + expect(pluginSource).toContain("'public_asset_disk' => ["); + expect(defaults.defaults).toHaveProperty('public_asset_disk'); + }); +}); + +describe('CKEditor5 공개 자산 디스크 필드 — 렌더링', () => { + let registry: ComponentRegistry; + let testUtils: ReturnType | null = null; + + beforeEach(() => { + registry = setupTestRegistry(); + // 표현식 내 $t() 는 컨텍스트에 $templateId 가 없으면 __templateApp.getConfig() + // 로 회수한다(engine-v1.38.2 폴백). layoutTestUtils 는 이를 세팅하지 않으므로 + // 테스트에서 동일 폴백 경로를 제공해야 옵션 계산식의 $t() 가 해석된다. + (window as any).__templateApp = { + getConfig: () => ({ templateId: 'test-template', locale: 'ko' }), + }; + }); + + afterEach(() => { + testUtils?.cleanup(); + testUtils = null; + (registry as any).registry = {}; + delete (window as any).__templateApp; + }); + + /** + * 필드 렌더링용 테스트 유틸을 생성한다. + * + * @returns createLayoutTest 유틸 + */ + function makeUtils() { + return createLayoutTest( + { + version: '1.0.0', + layout_name: 'test_ckeditor5_public_asset_field', + components: [publicAssetField], + } as any, + { + auth: { + isAuthenticated: true, + user: { id: 1, name: 'Admin', role: 'super_admin' }, + authType: 'admin', + }, + translations, + locale: 'ko', + initialState: { + _local: { + // 플러그인 설정 GET 응답(initLocal: form)에 서버 부착되는 형태 + form: { + public_asset_disk: '', + available_public_asset_disks: CATALOG, + }, + }, + }, + }, + ); + } + + it('라벨/힌트가 다국어 해석되어 렌더된다', async () => { + testUtils = makeUtils(); + const { container } = await testUtils.render(); + + expect(container.querySelector('label.form-label')?.textContent ?? '').toContain( + '공개 자산 디스크', + ); + expect(container.querySelector('p.form-hint')?.textContent ?? '').toContain( + '에디터 이미지 서빙 디스크를 오버라이드합니다.', + ); + expect(() => testUtils!.assertNoValidationErrors()).not.toThrow(); + }); + + it('설정 응답 부착 카탈로그가 옵션으로 계산된다 (코어 따름 선두 + $localized 매핑)', async () => { + testUtils = makeUtils(); + const { container } = await testUtils.render(); + + const select = container.querySelector('select[data-name="public_asset_disk"]'); + expect(select).not.toBeNull(); + + const options = Array.from(select!.querySelectorAll('option')); + expect(options.map((o) => o.getAttribute('value'))).toEqual(['', 'none', 'fake_cdn']); + expect(options.map((o) => o.textContent)).toEqual([ + '코어 설정 따름', + '사용 안 함 (스트리밍)', + '가짜 CDN', + ]); + }); +}); diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json index d7baae0a..d029661e 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/en.json @@ -29,6 +29,11 @@ "label": "Max Image Size (MB)", "hint": "Maximum allowed size for uploaded images. (1 ~ 10 MB)" }, + "public_asset_disk": { + "label": "Public Asset Disk", + "hint": "Select to use a different disk for this plugin only. Leave empty to follow the core public asset disk setting.", + "follow_core": "Follow core setting" + }, "editor_height": { "label": "Editor Height (px)", "hint": "Set the minimum height of the editor in pixels. (200 ~ 2000 px)" diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json index 8f463bee..8363aef3 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/lang/ko.json @@ -29,6 +29,11 @@ "label": "최대 이미지 크기 (MB)", "hint": "업로드 가능한 이미지의 최대 크기입니다. (1 ~ 10 MB)" }, + "public_asset_disk": { + "label": "공개 자산 디스크", + "hint": "이 플러그인만 다른 디스크를 쓰려면 선택합니다. 비우면 코어 공개 자산 디스크 설정을 따릅니다.", + "follow_core": "코어 설정 따름" + }, "editor_height": { "label": "에디터 높이 (px)", "hint": "에디터의 최소 높이를 픽셀 단위로 설정합니다. (200 ~ 2000 px)" diff --git a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json index ca0c1831..771d970a 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-ckeditor5/resources/layouts/admin/plugin_settings.json @@ -380,6 +380,49 @@ ] } ] + }, + { + "id": "field_public_asset_disk", + "type": "basic", + "name": "Div", + "props": { "className": "section-heading-md" }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-1" }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { "className": "form-label" }, + "text": "$t:sirsoft-ckeditor5.settings.fields.public_asset_disk.label" + }, + { + "type": "basic", + "name": "P", + "props": { "className": "form-hint" }, + "text": "$t:sirsoft-ckeditor5.settings.fields.public_asset_disk.hint" + } + ] + }, + { + "type": "basic", + "name": "Div", + "props": { "className": "lg:col-span-2" }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "public_asset_disk", + "className": "w-full", + "options": "{{[{value: '', label: $t('sirsoft-ckeditor5.settings.fields.public_asset_disk.follow_core')}].concat((_local.form?.available_public_asset_disks ?? []).map(d => ({value: d.id, label: $localized(d.label)})))}}" + } + } + ] + } + ] } ] } diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Models/Ckeditor5ImageUpload.php b/plugins/_bundled/sirsoft-ckeditor5/src/Models/Ckeditor5ImageUpload.php index f4e7d4c0..0ddb3a90 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/src/Models/Ckeditor5ImageUpload.php +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Models/Ckeditor5ImageUpload.php @@ -2,9 +2,12 @@ namespace Plugins\Sirsoft\Ckeditor5\Models; +use App\Contracts\Extension\StorageInterface; +use App\Extension\Storage\PluginStorageDriver; +use App\Models\User; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use App\Models\User; +use Illuminate\Support\Carbon; /** * CKEditor5 이미지 업로드 기록 모델 @@ -17,8 +20,8 @@ use App\Models\User; * @property int $file_size 파일 크기 (bytes) * @property string $mime_type MIME 타입 * @property int|null $uploaded_by 업로드 사용자 ID - * @property \Illuminate\Support\Carbon $created_at - * @property \Illuminate\Support\Carbon $updated_at + * @property Carbon $created_at + * @property Carbon $updated_at * @property-read string $download_url 이미지 서빙 URL */ class Ckeditor5ImageUpload extends Model @@ -74,13 +77,59 @@ class Ckeditor5ImageUpload extends Model } /** - * 이미지 서빙 URL 반환 (API 라우트 경유) + * 행 disk 별 스토리지 드라이버 캐시 (직렬화 루프 대응 memoize) * - * @return string 이미지 서빙 URL + * @var array + */ + private static array $storageByDisk = []; + + /** + * 이미지 서빙 URL 반환 — 직접 URL(CDN) 우선, 불가 시 API 라우트 폴백 + * + * 행에 기록된 storage_disk 로 직접 URL 생성을 시도하고, 불가하면(로컬 디스크, + * url 미설정 디스크, 훅 차단 등) 기존 API 서빙 경로로 폴백합니다. + * + * @return string 이미지 URL */ public function getDownloadUrlAttribute(): string { - return '/api/plugins/sirsoft-ckeditor5/images/'.$this->hash; + return $this->resolveDirectUrl() ?? '/api/plugins/sirsoft-ckeditor5/images/'.$this->hash; + } + + /** + * 행 storage_disk 기준 직접 URL 해석을 시도합니다. + * + * file_path 는 카테고리 prefix 를 포함하므로("images/..." 또는 "ckeditor5/..." 등 + * 외부 생성 행 포함) 첫 세그먼트를 카테고리로 분해하는 일반형으로 처리합니다. + * + * @return string|null 직접 URL (불가하면 null) + */ + protected function resolveDirectUrl(): ?string + { + $disk = (string) ($this->storage_disk ?? ''); + $filePath = (string) ($this->file_path ?? ''); + + if ($disk === '' || $filePath === '' || ! str_contains($filePath, '/')) { + return null; + } + + [$category, $relativePath] = explode('/', $filePath, 2); + + if ($category === '' || $relativePath === '') { + return null; + } + + $storage = self::$storageByDisk[$disk] ??= new PluginStorageDriver('sirsoft-ckeditor5', $disk); + + return $storage->url($category, $relativePath); + } + + /** + * 행 disk 드라이버 memoize 캐시를 비웁니다 (테스트 격리용). + */ + public static function flushStorageCache(): void + { + self::$storageByDisk = []; } /** diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php b/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php index e0bf62fb..5c656c10 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Providers/Ckeditor5ServiceProvider.php @@ -23,7 +23,19 @@ class Ckeditor5ServiceProvider extends BasePluginServiceProvider ]; protected array $storageServices = [ - ImageUploadService::class, ImageServeService::class, ]; + + /** + * 카테고리별 StorageInterface가 필요한 서비스 매핑 (클래스 ⇒ 카테고리) + * + * 업로드 서비스는 getStorageDiskFor('images') 가 결정한 디스크(공개 자산 디스크 + * 설정 반영)를 주입받아, put/getDisk() 행 기록이 자동으로 카테고리 디스크를 따릅니다. + * 서빙 서비스는 행 storage_disk 기준 withDisk() 를 쓰므로 기본 주입을 유지합니다. + * + * @var array + */ + protected array $storageCategoryServices = [ + ImageUploadService::class => 'images', + ]; } diff --git a/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageServeService.php b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageServeService.php index c8c0252f..37e4287c 100644 --- a/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageServeService.php +++ b/plugins/_bundled/sirsoft-ckeditor5/src/Services/ImageServeService.php @@ -43,27 +43,41 @@ class ImageServeService */ public function serve(Ckeditor5ImageUpload $image): ?StreamedResponse { - // file_path 형태: "images/2026/04/06/{uuid}.jpg" - // PluginStorageDriver::response(category, path) 에서 category = 'images' - $category = 'images'; - $relativePath = substr($image->file_path, strlen($category) + 1); + // file_path 형태: "images/2026/04/06/{uuid}.jpg" (외부 생성 행은 "ckeditor5/..." 등) + // 첫 세그먼트 = 카테고리 일반형으로 분해 — PluginStorageDriver::response(category, path) + [$category, $relativePath] = array_pad(explode('/', $image->file_path, 2), 2, ''); - $response = $this->storage->response( + if ($category === '' || $relativePath === '') { + return null; + } + + // 행에 기록된 disk 기준 서빙 — 디스크 전환 이전 행도 실제 저장 위치를 향한다. + // 단 고아 disk(디스크를 제공하던 플러그인 비활성화로 config 에서 사라진 경우)는 + // 미등록 disk 로 withDisk 를 만들면 response 가 InvalidArgumentException 을 던져 + // 서빙이 500 이 되므로, 존재 검증 후 주입 스토리지로 폴백한다. + $rowDisk = (string) ($image->storage_disk ?? ''); + $useRowDisk = $rowDisk !== '' + && $rowDisk !== $this->storage->getDisk() + && config("filesystems.disks.{$rowDisk}") !== null; + + $storage = $useRowDisk ? $this->storage->withDisk($rowDisk) : $this->storage; + + $response = $storage->response( $category, $relativePath, $image->original_name, [ - 'Content-Type' => $image->mime_type, + 'Content-Type' => $image->mime_type, 'Cache-Control' => 'public, max-age=31536000', ] ); if (! $response) { Log::error('CKEditor5 이미지 스토리지에 없음', [ - 'image_id' => $image->id, - 'hash' => $image->hash, + 'image_id' => $image->id, + 'hash' => $image->hash, 'file_path' => $image->file_path, - 'disk' => $image->storage_disk, + 'disk' => $image->storage_disk, ]); return null; diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Ckeditor5ImageUploadUrlTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Ckeditor5ImageUploadUrlTest.php new file mode 100644 index 00000000..fa51d50b --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Ckeditor5ImageUploadUrlTest.php @@ -0,0 +1,126 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + Ckeditor5ImageUpload::flushStorageCache(); + } + + protected function tearDown(): void + { + Ckeditor5ImageUpload::flushStorageCache(); + HookManager::resetAll(); + + parent::tearDown(); + } + + /** + * @scenario consumer=editor, disk_setting=public, e2e=ecommerce_settings_field, hook=unregistered, override=follow_core, row_state=legacy_local_row + * + * @effects download_url_falls_back_to_api_path_when_direct_unavailable + */ + #[Test] + public function it_falls_back_to_api_url_for_plugins_disk_row(): void + { + $image = new Ckeditor5ImageUpload([ + 'hash' => 'aaaabbbbcccc', + 'file_path' => 'images/2026/01/01/a.jpg', + 'storage_disk' => 'plugins', + ]); + + $this->assertSame( + '/api/plugins/sirsoft-ckeditor5/images/aaaabbbbcccc', + $image->download_url + ); + } + + #[Test] + public function it_returns_direct_url_for_cdn_disk_row(): void + { + $image = new Ckeditor5ImageUpload([ + 'hash' => 'aaaabbbbcccc', + 'file_path' => 'images/2026/01/01/a.jpg', + 'storage_disk' => 'fake_cdn', + ]); + + $this->assertSame( + 'https://cdn.test/assets/sirsoft-ckeditor5/images/2026/01/01/a.jpg', + $image->download_url + ); + } + + #[Test] + public function it_decomposes_non_images_prefix_generically(): void + { + // ApiDocSampleService 등 외부 생성 행 — 첫 세그먼트=카테고리 일반형 + $image = new Ckeditor5ImageUpload([ + 'hash' => 'aaaabbbbcccc', + 'file_path' => 'ckeditor5/apidoc-sample.png', + 'storage_disk' => 'fake_cdn', + ]); + + $this->assertSame( + 'https://cdn.test/assets/sirsoft-ckeditor5/ckeditor5/apidoc-sample.png', + $image->download_url + ); + } + + #[Test] + public function it_falls_back_when_file_path_has_no_prefix(): void + { + $image = new Ckeditor5ImageUpload([ + 'hash' => 'aaaabbbbcccc', + 'file_path' => 'orphan.png', + 'storage_disk' => 'fake_cdn', + ]); + + $this->assertSame( + '/api/plugins/sirsoft-ckeditor5/images/aaaabbbbcccc', + $image->download_url + ); + } + + /** + * @scenario consumer=editor, disk_setting=fake_cdn, e2e=drivers_tab_card, hook=block, override=module_override, row_state=legacy_local_row + * + * @effects hook_supply_modify_block_respected + */ + #[Test] + public function hook_block_falls_back_to_api_url(): void + { + HookManager::addFilter('core.storage.filter_url', fn ($url) => ''); + + $image = new Ckeditor5ImageUpload([ + 'hash' => 'aaaabbbbcccc', + 'file_path' => 'images/2026/01/01/a.jpg', + 'storage_disk' => 'fake_cdn', + ]); + + $this->assertSame( + '/api/plugins/sirsoft-ckeditor5/images/aaaabbbbcccc', + $image->download_url + ); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/ImageServeServiceRowDiskTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/ImageServeServiceRowDiskTest.php new file mode 100644 index 00000000..94c28316 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/ImageServeServiceRowDiskTest.php @@ -0,0 +1,236 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + Ckeditor5ImageUpload::flushStorageCache(); + + $this->service = app(ImageServeService::class); + } + + protected function tearDown(): void + { + Ckeditor5ImageUpload::flushStorageCache(); + + parent::tearDown(); + } + + /** + * 지정 disk 에 파일과 업로드 행을 함께 생성합니다. + * + * @param string $disk 행 storage_disk + * @param string $filePath "카테고리/상대경로" 형태의 file_path + * @return Ckeditor5ImageUpload 생성된 행 + */ + private function makeRowOnDisk(string $disk, string $filePath): Ckeditor5ImageUpload + { + [$category, $relativePath] = explode('/', $filePath, 2); + + (new PluginStorageDriver('sirsoft-ckeditor5', $disk))->put($category, $relativePath, 'image-bytes'); + + return Ckeditor5ImageUpload::create([ + 'original_name' => basename($filePath), + 'file_path' => $filePath, + 'storage_disk' => $disk, + 'file_size' => 11, + 'mime_type' => 'image/jpeg', + 'uploaded_by' => null, + ]); + } + + /** + * @effects serve_and_delete_follow_row_disk, mixed_rows_resolve_per_row_disk + */ + #[Test] + public function serve_streams_row_from_its_recorded_disk(): void + { + // 혼재 운용 — 주입 스토리지 disk('plugins')와 다른 disk 의 행도 서빙 가능해야 한다 + $cdnRow = $this->makeRowOnDisk('fake_cdn', 'images/row-disk-test/cdn.jpg'); + $localRow = $this->makeRowOnDisk('plugins', 'images/row-disk-test/local.jpg'); + + $this->assertInstanceOf(StreamedResponse::class, $this->service->serve($cdnRow)); + $this->assertInstanceOf(StreamedResponse::class, $this->service->serve($localRow)); + } + + /** + * 외부 생성 행(첫 세그먼트가 'images' 가 아닌 경우)도 카테고리 일반형 분해로 + * 행 disk 에서 서빙된다 — ApiDoc 샘플(`ckeditor5/apidoc-sample.png`) 형태. + * + * @effects serve_and_delete_follow_row_disk + */ + #[Test] + public function serve_resolves_non_images_prefix_on_row_disk(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'ckeditor5/row-disk-sample.png'); + + $this->assertInstanceOf(StreamedResponse::class, $this->service->serve($row)); + } + + /** + * 행 disk 에 파일이 없으면 주입 디스크로 몰래 폴백하지 않고 null 을 반환한다 + * (다른 디스크의 동명 파일을 잘못 서빙하는 회귀 차단). + * + * @effects serve_and_delete_follow_row_disk + */ + #[Test] + public function serve_returns_null_when_file_absent_on_row_disk(): void + { + // 파일은 주입 디스크에만 두고, 행은 CDN 디스크로 기록 + (new PluginStorageDriver('sirsoft-ckeditor5', 'plugins')) + ->put('images', 'row-disk-test/only-local.jpg', 'image-bytes'); + + $row = Ckeditor5ImageUpload::create([ + 'original_name' => 'only-local.jpg', + 'file_path' => 'images/row-disk-test/only-local.jpg', + 'storage_disk' => 'fake_cdn', + 'file_size' => 11, + 'mime_type' => 'image/jpeg', + 'uploaded_by' => null, + ]); + + $this->assertNull($this->service->serve($row)); + } + + /** + * 고아 disk 행의 서빙은 500 이 아니라 스트리밍 폴백이어야 한다. + * + * 디스크를 제공하던 플러그인이 비활성화되면 행 storage_disk 가 config 에서 사라진다. + * 이때 미등록 disk 로 withDisk 를 만들면 response 가 InvalidArgumentException 을 + * 던져 에디터 이미지 서빙이 500 이 된다. + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function serve_falls_back_when_row_disk_is_orphaned(): void + { + $row = $this->makeRowOnDisk('fake_cdn', 'images/row-disk-test/orphan.jpg'); + + Config::set('filesystems.disks.fake_cdn', null); + Storage::forgetDisk('fake_cdn'); + Ckeditor5ImageUpload::flushStorageCache(); + + // 예외 없이 폴백 — 파일은 도달 불가이므로 null(404) 이 정상 degradation + $this->assertNull($this->service->serve($row)); + } + + /** + * SP 배선(`$storageCategoryServices`)이 주입하는 것과 동일한 스토리지로 서비스를 만듭니다. + * + * PluginTestCase 는 ImageUploadService 의 StorageInterface 를 'plugins' 고정으로 + * 하드바인딩하므로, 컨테이너 해석으로는 카테고리 디스크 배선을 검증할 수 없습니다. + * 여기서는 실제 플러그인 인스턴스의 getStorageFor('images') 를 직접 주입해 + * 배선이 주는 것과 같은 조건을 재현합니다. + */ + private function makeUploadServiceWithCategoryStorage(): ImageUploadService + { + $plugin = app(PluginManager::class)->getPlugin('sirsoft-ckeditor5') + ?? new Plugin(base_path('plugins/sirsoft-ckeditor5')); + + return new ImageUploadService( + app(ImageUploadRepositoryInterface::class), + $plugin->getStorageFor('images') + ); + } + + /** + * 플러그인 개별 오버라이드를 지정 값으로 고정합니다. + * + * PluginTestCase 는 플러그인 설정을 격리하지 않아, 고정하지 않으면 + * `plugin_setting()` 이 개발 머신의 실제 저장값(storage/app/plugins/…)을 읽는다. + * 오버라이드는 코어 전역보다 우선하므로 그 값이 남아 있으면 전역 설정을 세우는 + * 이 테스트가 개발자 환경에 따라 통과/실패한다 — 두 입력을 모두 통제한다. + * + * @param string $override 플러그인 설정 public_asset_disk 값 ('' = 코어 따름) + */ + private function fixPluginOverride(string $override): void + { + $stub = new class($override) extends PluginSettingsService + { + public function __construct(private string $override) {} + + public function get(string $identifier, ?string $key = null, mixed $default = null): mixed + { + if ($key === 'public_asset_disk') { + return $this->override; + } + + return $default; + } + }; + + $this->app->instance(PluginSettingsService::class, $stub); + } + + /** + * put 종단 — 공개 자산 디스크 설정값이 신규 행의 storage_disk 에 기록되고, + * 그 행이 다시 자기 disk 로 서빙되는지 단언합니다(배선 회귀 검출). + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_configured_public_asset_disk_on_row(): void + { + $this->fixPluginOverride(''); + Config::set('core.storage.public_asset_disk', 'fake_cdn'); + + $record = $this->makeUploadServiceWithCategoryStorage() + ->upload(UploadedFile::fake()->image('cdn-editor.jpg', 40, 40), null); + + $this->assertSame('fake_cdn', $record->storage_disk); + $this->assertInstanceOf(StreamedResponse::class, $this->service->serve($record)); + } + + /** + * put 종단 — 공개 자산 디스크 미설정이면 신규 행이 기본 디스크로 기록(현행 보존). + * + * @effects mixed_rows_resolve_per_row_disk + */ + #[Test] + public function upload_records_base_disk_when_public_asset_disk_unset(): void + { + $this->fixPluginOverride(''); + Config::set('core.storage.public_asset_disk', ''); + + $record = $this->makeUploadServiceWithCategoryStorage() + ->upload(UploadedFile::fake()->image('local-editor.jpg', 40, 40), null); + + $this->assertSame('plugins', $record->storage_disk); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsCatalogAttachTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsCatalogAttachTest.php new file mode 100644 index 00000000..44c19219 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsCatalogAttachTest.php @@ -0,0 +1,67 @@ +show('sirsoft-ckeditor5'); + + $this->assertSame(200, $response->getStatusCode()); + + $data = $response->getData(true)['data'] ?? []; + $this->assertArrayHasKey('available_public_asset_disks', $data); + + $ids = array_column($data['available_public_asset_disks'], 'id'); + $this->assertContains('none', $ids); + $this->assertContains('public', $ids); + $this->assertContains('s3', $ids); + } + + #[Test] + public function catalog_is_not_attached_when_schema_lacks_public_asset_disk(): void + { + // 스키마에 public_asset_disk 가 없는 플러그인 시뮬레이션 — 게이트가 닫혀야 한다 + $plugin = $this->createMock(AbstractPlugin::class); + $plugin->method('getSettingsSchema')->willReturn([ + 'some_other_key' => ['type' => 'string'], + ]); + + $manager = $this->createMock(PluginManager::class); + $manager->method('getPlugin')->willReturn($plugin); + $this->app->instance(PluginManager::class, $manager); + + $response = app(PluginSettingsController::class)->show('sirsoft-ckeditor5'); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertArrayNotHasKey( + 'available_public_asset_disks', + $response->getData(true)['data'] ?? [] + ); + } + + protected function tearDown(): void + { + $this->app->forgetInstance(PluginManager::class); + + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsPublicAssetDiskValidationTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsPublicAssetDiskValidationTest.php new file mode 100644 index 00000000..c5274b95 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsPublicAssetDiskValidationTest.php @@ -0,0 +1,119 @@ + $disk, + ]); + $request->setContainer($this->app)->setRedirector($this->app['redirect']); + $request->setRouteResolver(fn () => new class + { + public function parameter(string $key, $default = null): string + { + return 'sirsoft-ckeditor5'; + } + }); + $request->validateResolved(); + + return $request; + } + + #[Test] + public function core_catalog_disk_is_accepted(): void + { + $request = $this->resolve('public'); + + $this->assertSame('public', $request->validated()['public_asset_disk']); + } + + #[Test] + public function empty_value_is_accepted_as_follow_core(): void + { + $request = $this->resolve(''); + + $this->assertSame('', $request->validated()['public_asset_disk'] ?? ''); + } + + #[Test] + public function unknown_disk_is_rejected(): void + { + $this->expectException(ValidationException::class); + + try { + $this->resolve('nonexistent_disk'); + } catch (ValidationException $e) { + $this->assertArrayHasKey( + 'public_asset_disk', + $e->validator->errors()->toArray(), + '카탈로그에 없는 디스크는 public_asset_disk 오류를 내야 한다' + ); + + throw $e; + } + } + + #[Test] + public function validation_gate_is_closed_when_schema_lacks_the_key(): void + { + // 이 키를 선언하지 않은 플러그인에는 검증도 붙지 않아야 한다 — + // 부착 게이트(PluginSettingsController)와 검증 게이트가 같은 조건이어야 한다. + $plugin = $this->createMock(AbstractPlugin::class); + $plugin->method('getSettingsSchema')->willReturn([ + 'some_other_key' => ['type' => 'string'], + ]); + + $manager = $this->createMock(PluginManager::class); + $manager->method('getPlugin')->willReturn($plugin); + $this->app->instance(PluginManager::class, $manager); + + $request = UpdatePluginSettingsRequest::create('/', 'PUT', [ + 'some_other_key' => 'value', + ]); + $request->setContainer($this->app)->setRedirector($this->app['redirect']); + $request->setRouteResolver(fn () => new class + { + public function parameter(string $key, $default = null): string + { + return 'other-plugin'; + } + }); + + $this->assertArrayNotHasKey('public_asset_disk', $request->rules()); + } + + protected function tearDown(): void + { + $this->app->forgetInstance(PluginManager::class); + + parent::tearDown(); + } +} diff --git a/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/StorageDiskRecursionTest.php b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/StorageDiskRecursionTest.php new file mode 100644 index 00000000..04c5ff41 --- /dev/null +++ b/plugins/_bundled/sirsoft-ckeditor5/tests/Unit/StorageDiskRecursionTest.php @@ -0,0 +1,126 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + Config::set('core.storage.public_asset_disk', ''); + + $this->plugin = app(PluginManager::class)->getPlugin('sirsoft-ckeditor5') + ?? new Plugin(base_path('plugins/sirsoft-ckeditor5')); + + // 실제 설정 로드처럼 get() 내부에서 플러그인 스토리지('settings')를 재요구하는 스파이. + // 재진입(= getStorageDiskFor('settings') 가 다시 설정을 조회하는 회귀) 발생 시 + // reentered 플래그 + 호출 횟수 증가로 검출됩니다. + $this->settingsSpy = new class extends PluginSettingsService + { + public ?Plugin $plugin = null; + + public int $calls = 0; + + public bool $reentered = false; + + public ?string $nestedDisk = null; + + private bool $inGet = false; + + public function __construct() {} + + public function get(string $identifier, ?string $key = null, mixed $default = null): mixed + { + $this->calls++; + + if ($this->inGet) { + // 설정 로드 중 설정 재조회 = 순환 고리. 무한 재귀 대신 폭로. + $this->reentered = true; + + return $default; + } + + $this->inGet = true; + + try { + // 설정 로드가 스토리지를 경유하는 실제 경로 재현 — + // 이 호출이 getStorageDiskFor('settings') 를 타고 되돌아온다. + $this->nestedDisk = $this->plugin->getStorageFor('settings')->getDisk(); + + if ($key === 'public_asset_disk') { + return 'fake_cdn'; + } + + return $default; + } finally { + $this->inGet = false; + } + } + }; + $this->settingsSpy->plugin = $this->plugin; + $this->app->instance(PluginSettingsService::class, $this->settingsSpy); + } + + protected function tearDown(): void + { + $this->app->forgetInstance(PluginSettingsService::class); + + parent::tearDown(); + } + + /** + * @effects settings_load_recursion_free + */ + #[Test] + public function settings_category_never_consults_plugin_settings(): void + { + // 'settings' 카테고리 해석은 설정 서비스를 한 번도 거치지 않아야 한다 — + // 거치는 순간 설정 로드 ↔ 디스크 해석의 순환 고리가 열린다. + $this->assertSame($this->plugin->getStorageDisk(), $this->plugin->getStorageDiskFor('settings')); + $this->assertSame(0, $this->settingsSpy->calls); + } + + /** + * @effects settings_load_recursion_free + */ + #[Test] + public function images_resolution_survives_settings_load_that_uses_storage(): void + { + // 공개 자산 카테고리 해석 → 설정 조회 → (설정 로드가 'settings' 스토리지 재요구) + // 전체 사슬이 유한 종료하고, 설정 조회는 정확히 1회만 발생해야 한다. + $this->assertSame('fake_cdn', $this->plugin->getStorageDiskFor('images')); + + $this->assertSame(1, $this->settingsSpy->calls); + $this->assertFalse($this->settingsSpy->reentered, '설정 로드 중 설정 재조회(순환) 발생'); + // 설정 로드가 요구한 'settings' 스토리지는 공개 자산 디스크가 아닌 기본 디스크 + $this->assertSame($this->plugin->getStorageDisk(), $this->settingsSpy->nestedDisk); + } +} diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 77cbb20a..282429cd 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -9,13 +9,14 @@ ### Added - 환경설정 > 드라이버 > 파일 스토리지에 S3 호환 스토리지 설정이 추가되었습니다 — 엔드포인트 URL 입력칸과 Path-style 주소 토글로 Cloudflare R2, MinIO 등도 연결할 수 있습니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.) +- 환경설정 > 드라이버 탭에 「공개 자산 스토리지」 카드가 추가되었습니다. 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)을 직접 URL(CDN)로 서빙할 디스크를 고를 수 있으며, 기본값(사용 안 함)이면 종전 스트리밍 방식 그대로입니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.) ### Changed - S3 리전이 5개 목록 선택에서 자유 입력으로 바뀌었습니다 — 새로 생기는 AWS 리전이나 R2 의 `auto` 값을 그대로 입력할 수 있습니다. - S3 URL 항목의 이름과 설명을 "공개 URL(CDN)" 용도로 명확히 했습니다 — API 요청 주소는 새로 생긴 엔드포인트 URL 칸에 입력합니다. - 웹소켓 연결 테스트가 서버 발송용 주소까지 함께 검사하도록 테스트 요청 항목이 확장되었습니다. -- 코어 최소 요구 버전을 7.0.7 로 상향했습니다 (S3 엔드포인트·리전 자유 입력 등 신규 설정 키가 코어 7.0.7 부터 지원됩니다). +- 코어 최소 요구 버전을 7.0.7 로 상향했습니다 (S3 엔드포인트·리전 자유 입력, 공개 자산 스토리지 등 신규 설정 키가 코어 7.0.7 부터 지원됩니다). ### Fixed diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-public-asset-storage.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-public-asset-storage.test.tsx new file mode 100644 index 00000000..a52b5948 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-public-asset-storage.test.tsx @@ -0,0 +1,270 @@ +/** + * 드라이버 탭 — 공개 자산 스토리지 카드 테스트 (공개#100) + * + * @description + * 신규 "공개 자산 스토리지" 카드는 storage 카드(#99 작업 구역)의 **형제**로 존재해야 + * 하며, Select 는 저장 키(drivers.public_asset_disk)와 서버 카탈로그 + * (available_drivers.public_asset)를 정확히 바인딩해야 한다. 화면 옵션이 카탈로그를 + * 벗어나면 저장 단계(레지스트리 조회 closure rule)에서 422 가 된다. + * + * 계약 단언(JSON 구조 + 저장 규칙 대조)에 더해, 실제 카드 서브트리를 + * createLayoutTest 로 렌더링해 옵션 계산식($localized 매핑)과 s3 도움말 조건부 + * 렌더링까지 검증한다. + * + * @effects settings_catalog_includes_plugin_registered_disks + */ + +import React from 'react'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { createLayoutTest } from '@core/template-engine/__tests__/utils/layoutTestUtils'; +import { ComponentRegistry } from '@core/template-engine/ComponentRegistry'; + +import tabDrivers from '../../layouts/partials/admin_settings/_tab_drivers.json'; + +// ─── 테스트용 컴포넌트 ─── + +const TestDiv: React.FC<{ className?: string; children?: React.ReactNode; 'data-testid'?: string }> = + ({ className, children, 'data-testid': testId }) => ( +
{children}
+ ); + +const TestH3: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) =>

{children || text}

; + +const TestLabel: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) => ; + +const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> = + ({ className, children, text }) => {children || text}; + +const TestSelect: React.FC<{ + name?: string; value?: string; className?: string; options?: any[]; disabled?: boolean; + error?: string; onChange?: (e: React.ChangeEvent) => void; +}> = ({ name, value, className, options, disabled, onChange }) => ( + +); + +// 렌더러가 컴포넌트 목록을 Fragment 로 감싸므로 반드시 등록해야 한다. +// 미등록 시 트리 전체가 렌더되지 않아 "없어야 한다" 계열 단언이 거짓 통과한다. +const TestFragment: React.FC<{ children?: React.ReactNode }> = ({ children }) => <>{children}; + +function setupTestRegistry(): ComponentRegistry { + const registry = ComponentRegistry.getInstance(); + (registry as any).registry = { + Div: { component: TestDiv, metadata: { name: 'Div', type: 'basic' } }, + H3: { component: TestH3, metadata: { name: 'H3', type: 'basic' } }, + Label: { component: TestLabel, metadata: { name: 'Label', type: 'basic' } }, + Span: { component: TestSpan, metadata: { name: 'Span', type: 'basic' } }, + Select: { component: TestSelect, metadata: { name: 'Select', type: 'composite' } }, + Fragment: { component: TestFragment, metadata: { name: 'Fragment', type: 'layout' } }, + }; + return registry; +} + +// ─── 다국어 (드라이버 탭 공개 자산 카드가 쓰는 키) ─── + +const translations = { + admin: { + settings: { + drivers: { + public_asset: { + title: '공개 자산 스토리지', + desc: '완전 공개 자산의 직접 URL 서빙 디스크를 설정합니다.', + disk: '공개 자산 디스크', + s3_help: '스토리지 카드의 S3 URL 설정이 필요합니다.', + }, + }, + }, + }, +}; + +/** + * 저장소 루트를 위로 훑어 찾는다. + * + * @returns 저장소 루트 절대경로 + */ +function repositoryRoot(): string { + let current = path.dirname(fileURLToPath(import.meta.url)); + + for (let depth = 0; depth < 10; depth++) { + if (fs.existsSync(path.join(current, 'config/core.php'))) { + return current; + } + current = path.dirname(current); + } + + throw new Error('저장소 루트를 찾지 못했습니다.'); +} + +/** + * 레이아웃 트리에서 조건을 만족하는 첫 노드를 찾는다. + * + * @param node 탐색 시작 노드 + * @param predicate 노드 판정 함수 + * @returns 찾은 노드 또는 null + */ +function findNode(node: any, predicate: (n: any) => boolean): any { + if (!node || typeof node !== 'object') return null; + if (predicate(node)) return node; + + for (const child of node.children ?? []) { + const found = findNode(child, predicate); + if (found) return found; + } + + return null; +} + +// 실제 카드 서브트리를 그대로 렌더 대상으로 쓴다 — 사본 표현식이 아닌 배포 JSON 자체가 SSoT. +const publicAssetCard = (tabDrivers as any).children.find( + (c: any) => c.id === 'card_public_asset_settings', +); + +const CATALOG = [ + { id: 'none', label: { ko: '사용 안 함 (스트리밍)', en: 'None (streaming)' } }, + { id: 'public', label: { ko: '로컬 공개 디스크', en: 'Local public disk' } }, + { id: 'fake_cdn', label: { ko: '가짜 CDN', en: 'Fake CDN' } }, +]; + +/** + * 카드 렌더링용 테스트 유틸을 생성한다. + * + * @param selectedDisk 현재 저장된 공개 자산 디스크 값 + * @returns createLayoutTest 유틸 + */ +function makeUtils(selectedDisk: string) { + return createLayoutTest( + { + version: '1.0.0', + layout_name: 'test_public_asset_storage_card', + components: [publicAssetCard], + } as any, + { + auth: { + isAuthenticated: true, + user: { id: 1, name: 'Admin', role: 'super_admin' }, + authType: 'admin', + }, + translations, + locale: 'ko', + initialState: { + _local: { + form: { + available_drivers: { public_asset: CATALOG }, + drivers: { public_asset_disk: selectedDisk }, + }, + errors: null, + }, + }, + }, + ); +} + +describe('드라이버 탭 공개 자산 스토리지 카드 — 계약', () => { + it('storage 카드의 형제로 카드가 존재한다 (#99 s3 블록 비접촉)', () => { + const ids = (tabDrivers as any).children.map((c: any) => c.id); + + expect(ids).toContain('card_storage_settings'); + expect(ids).toContain('card_public_asset_settings'); + // storage 카드 내부가 아닌 최상위 형제 배열에 위치 + expect(ids.indexOf('card_public_asset_settings')).toBeGreaterThan( + ids.indexOf('card_storage_settings'), + ); + }); + + it('Select 가 저장 키와 카탈로그를 정확히 바인딩한다', () => { + const select = findNode(publicAssetCard, (n) => n.name === 'Select'); + + expect(select).not.toBeNull(); + expect(select.props.name).toBe('drivers.public_asset_disk'); + expect(select.props.options).toContain('available_drivers?.public_asset'); + expect(select.props.error).toContain("['drivers.public_asset_disk']"); + }); + + it('s3 선택 시에만 도움말이 노출된다', () => { + const help = findNode(publicAssetCard, (n) => n.id === 'public_asset_s3_help'); + + expect(help).not.toBeNull(); + expect(help.if).toContain("public_asset_disk === 's3'"); + }); + + it('저장 규칙(SaveSettingsRequest)이 같은 키를 검증한다', () => { + const requestSource = fs.readFileSync( + path.join(repositoryRoot(), 'app/Http/Requests/Settings/SaveSettingsRequest.php'), + 'utf-8', + ); + + expect(requestSource).toContain("'drivers.public_asset_disk'"); + expect(requestSource).toContain("isDriverAvailable('public_asset'"); + }); +}); + +describe('드라이버 탭 공개 자산 스토리지 카드 — 렌더링', () => { + let registry: ComponentRegistry; + let testUtils: ReturnType | null = null; + + beforeEach(() => { + registry = setupTestRegistry(); + }); + + afterEach(() => { + testUtils?.cleanup(); + testUtils = null; + (registry as any).registry = {}; + }); + + it('카드 제목/라벨이 다국어 해석되어 렌더된다', async () => { + testUtils = makeUtils('public'); + const { container } = await testUtils.render(); + + expect(container.querySelector('h3.card-title')?.textContent ?? '').toContain( + '공개 자산 스토리지', + ); + expect(container.querySelector('label.form-label')?.textContent ?? '').toContain( + '공개 자산 디스크', + ); + expect(() => testUtils!.assertNoValidationErrors()).not.toThrow(); + }); + + it('Select 옵션이 서버 카탈로그에서 $localized 매핑으로 계산된다', async () => { + testUtils = makeUtils('public'); + const { container } = await testUtils.render(); + + const select = container.querySelector('select[data-name="drivers.public_asset_disk"]'); + expect(select).not.toBeNull(); + + const options = Array.from(select!.querySelectorAll('option')); + expect(options.map((o) => o.getAttribute('value'))).toEqual([ + 'none', + 'public', + 'fake_cdn', + ]); + // $localized(d.label) — 활성 로케일(ko) 라벨로 해석되어야 한다 + expect(options.map((o) => o.textContent)).toEqual([ + '사용 안 함 (스트리밍)', + '로컬 공개 디스크', + '가짜 CDN', + ]); + }); + + it('s3 미선택 시 도움말이 렌더되지 않는다', async () => { + testUtils = makeUtils('public'); + const { container } = await testUtils.render(); + + // 긍정 앵커 — 카드가 실제로 렌더됐음을 먼저 확인해야 아래 부정 단언이 의미를 갖는다 + expect(container.querySelector('select[data-name="drivers.public_asset_disk"]')).not.toBeNull(); + expect(container.textContent ?? '').not.toContain('S3 URL 설정'); + }); + + it('s3 선택 시 도움말이 렌더된다', async () => { + testUtils = makeUtils('s3'); + const { container } = await testUtils.render(); + + expect(container.textContent ?? '').toContain('스토리지 카드의 S3 URL 설정이 필요합니다.'); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index b9727ca7..b9456caa 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1637,6 +1637,12 @@ "s3_url": "S3 URL (Public URL)", "s3_url_desc": "CDN or custom domain used to build public file URLs. Enter the API address in the Endpoint URL field instead." }, + "public_asset": { + "title": "Public Asset Storage", + "desc": "Configure the disk for serving fully public assets (product, category, review, and editor images, etc.) via direct URLs. When disabled, the existing streaming method is used.", + "disk": "Disk", + "s3_help": "When selecting S3, the S3 URL (CDN domain) in the File Storage card above must be configured. Without it, streaming is used." + }, "cache": { "title": "Cache", "desc": "Configure application cache driver settings.", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index c33a0cc8..dda2feef 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1641,6 +1641,12 @@ "s3_url": "S3 URL (공개 URL)", "s3_url_desc": "파일 공개 URL 생성에 쓰는 CDN/커스텀 도메인입니다. API 요청 주소는 엔드포인트 URL에 입력하세요." }, + "public_asset": { + "title": "공개 자산 스토리지", + "desc": "완전 공개 자산(상품·카테고리·리뷰·에디터 이미지 등)의 직접 URL 서빙 디스크를 설정합니다. 사용 안 함이면 기존 스트리밍 방식으로 동작합니다.", + "disk": "디스크", + "s3_help": "S3 선택 시 위 파일 스토리지 카드의 S3 URL(CDN 도메인) 설정이 필요합니다. 미설정 시 스트리밍으로 동작합니다." + }, "cache": { "title": "캐시", "desc": "애플리케이션 캐시 드라이버를 설정합니다.", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json index d0b03264..d433ae2e 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_drivers.json @@ -557,6 +557,98 @@ } ] }, + { + "id": "card_public_asset_settings", + "type": "basic", + "name": "Div", + "props": { + "className": "admin-card" + }, + "children": [ + { + "type": "basic", + "name": "H3", + "props": { + "className": "card-title" + }, + "text": "$t:admin.settings.drivers.public_asset.title" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "card-description" + }, + "text": "$t:admin.settings.drivers.public_asset.desc" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "row-stack" + }, + "children": [ + { + "id": "field_public_asset_disk", + "type": "basic", + "name": "Div", + "props": { + "className": "" + }, + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:admin.settings.drivers.public_asset.disk" + }, + { + "type": "basic", + "name": "Div", + "props": { + "className": "w-full md:w-64" + }, + "children": [ + { + "type": "composite", + "name": "Select", + "props": { + "name": "drivers.public_asset_disk", + "disabled": "{{_computed.isReadOnly}}", + "className": "w-full", + "error": "{{_local.errors?.['drivers.public_asset_disk']?.[0] ?? ''}}", + "options": "{{(_local.form?.available_drivers?.public_asset ?? []).map(d => ({value: d.id, label: $localized(d.label)}))}}" + } + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['drivers.public_asset_disk']}}", + "props": { + "className": "form-error" + }, + "text": "{{_local.errors?.['drivers.public_asset_disk']?.[0] ?? ''}}" + } + ] + } + ] + }, + { + "id": "public_asset_s3_help", + "type": "basic", + "name": "Div", + "if": "{{_local.form?.drivers?.public_asset_disk === 's3'}}", + "props": { + "className": "text-sm text-gray-500 dark:text-gray-400 mt-2" + }, + "text": "$t:admin.settings.drivers.public_asset.s3_help" + } + ] + } + ] + }, { "id": "card_cache_settings", "type": "basic", diff --git a/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/public-asset-storage.spec.ts b/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/public-asset-storage.spec.ts new file mode 100644 index 00000000..75c1cade --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/public-asset-storage.spec.ts @@ -0,0 +1,166 @@ +/** + * E2E: 공개 자산 스토리지 설정 — 코어 드라이버 탭 카드 (공개#100) + * + * 시나리오 매니페스트: `tests/scenarios/public-asset-cdn.yaml` + * (케이스 마킹은 각 test 의 라인 주석에 있다) + * + * @effects settings_catalog_includes_plugin_registered_disks, + * invalid_disk_rejected_with_422 + * + * 드라이버 탭의 "공개 자산 스토리지" 카드가 카탈로그(none/public/s3)를 그리고, + * 선택-저장-재진입 왕복에서 값이 유지되는지 검증한다. 함께 있는 CKEditor5 축은 + * 코어 PluginSettingsController 의 카탈로그 부착을 제한 권한에서 검증하는 것이라 + * 코어/템플릿 표면에 둔다 — 이커머스 모듈이 소유한 오버라이드 필드는 모듈 spec + * (`modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/`) 이 담당한다. + * + * composite Select 는 네이티브 select 가 아니라 커스텀 드롭다운(root div 에 name 속성, + * Button 토글, portal listbox role=option)이다 — 상호작용은 그 구조를 따른다. + * 저장 후에는 반드시 원값으로 되돌려 사이트 설정을 바꾸지 않는다. + */ +import { test, expect, authenticatePage, issueToken } from '../fixtures/admin-template-auth'; +import type { Page } from '@playwright/test'; + +/** 코어 설정 읽기/쓰기 권한 토큰 (테스트 파일 단위 1회 발급) */ +let settingsToken: string; + +test.beforeAll(() => { + settingsToken = issueToken('core.settings.read', 'core.settings.update'); +}); + +const DRIVERS_TAB = '/admin/settings?tab=drivers'; +const CKEDITOR5_SETTINGS = '/admin/plugins/sirsoft-ckeditor5/settings'; + +const CORE_SELECT_ROOT = '[name="drivers.public_asset_disk"]'; +const PLUGIN_SELECT_ROOT = '[name="public_asset_disk"]'; + +/** + * 드라이버 탭 진입 — 폼 데이터 적재를 엔진 상태로 확인한다. + * + * DOM attach 만 기다리면 바인딩 전 빈 Select 를 카탈로그 누락으로 오인한다. + */ +async function gotoDriversTab(page: Page): Promise { + await page.goto(DRIVERS_TAB); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect + .poll( + async () => + await page.evaluate(() => { + const local = (window as any).G7Core?.state?.getLocal?.(); + return Array.isArray(local?.form?.available_drivers?.public_asset); + }), + { timeout: 20_000 }, + ) + .toBe(true); +} + +/** + * 커스텀 드롭다운을 열고 옵션 라벨 목록을 읽는다 (읽은 뒤 드롭다운은 닫지 않음). + * + * @param page Playwright 페이지 + * @param rootSelector Select 루트(name 속성) 선택자 + * @returns 옵션 라벨 배열 + */ +async function openAndReadOptions(page: Page, rootSelector: string): Promise { + await page.locator(`${rootSelector} button`).first().click(); + const listbox = page.locator('[role="listbox"]'); + await expect(listbox).toBeVisible({ timeout: 10_000 }); + + return listbox.locator('[role="option"]').allInnerTexts(); +} + +/** + * 엔진 로컬 상태에서 폼 값을 읽는다. + * + * @param page Playwright 페이지 + * @param path form 하위 도트 경로 (예: 'drivers.public_asset_disk') + * @returns 현재 폼 값 + */ +async function readFormValue(page: Page, path: string): Promise { + return page.evaluate((p) => { + const form = (window as any).G7Core?.state?.getLocal?.()?.form; + return p.split('.').reduce((acc: any, key: string) => acc?.[key], form); + }, path); +} + +test.describe('공개 자산 스토리지 설정', () => { + // @scenario consumer=product, disk_setting=none, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=legacy_local_row + // @effects settings_catalog_includes_plugin_registered_disks + test('드라이버 탭 카드가 코어 카탈로그(none/public/s3)를 그린다', async ({ page }) => { + await authenticatePage(page, settingsToken); + await gotoDriversTab(page); + + await expect(page.locator(CORE_SELECT_ROOT)).toBeVisible(); + + const labels = await openAndReadOptions(page, CORE_SELECT_ROOT); + expect(labels.join('|')).toContain('사용 안 함'); + expect(labels.join('|')).toContain('Public'); + expect(labels.join('|')).toContain('Amazon S3'); + + await page.keyboard.press('Escape'); + }); + + // @scenario consumer=product, disk_setting=public, e2e=save_roundtrip, hook=unregistered, override=module_override, row_state=new_remote_row + // @effects invalid_disk_rejected_with_422 + test('선택-저장-재진입 왕복에서 값이 유지되고 원복된다', async ({ page }) => { + test.setTimeout(120_000); + await authenticatePage(page, settingsToken); + await gotoDriversTab(page); + + const original = (await readFormValue(page, 'drivers.public_asset_disk')) as string; + + // Public 디스크 선택 → 저장 → 성공 토스트 + await openAndReadOptions(page, CORE_SELECT_ROOT); + await page.locator('[role="listbox"] [role="option"]', { hasText: 'Public' }).first().click(); + await expect.poll(() => readFormValue(page, 'drivers.public_asset_disk')).toBe('public'); + + await page.locator('#save_button button, button#save_button').first().click(); + await expect(page.getByText('설정이 저장되었습니다')).toBeVisible({ timeout: 20_000 }); + + // 재진입 — 서버 저장값이 폼으로 복원되는지 확인 (refetchOnMount) + await gotoDriversTab(page); + await expect.poll(() => readFormValue(page, 'drivers.public_asset_disk'), { timeout: 20_000 }).toBe('public'); + + // 원복 (사이트 설정 보존) — 원값 라벨('none' → '사용 안 함')로 되돌린다 + const restoreLabel = original === 'public' ? 'Public' : original === 's3' ? 'Amazon S3' : '사용 안 함'; + await openAndReadOptions(page, CORE_SELECT_ROOT); + await page.locator('[role="listbox"] [role="option"]', { hasText: restoreLabel }).first().click(); + await page.locator('#save_button button, button#save_button').first().click(); + await expect(page.getByText('설정이 저장되었습니다')).toBeVisible({ timeout: 20_000 }); + + await gotoDriversTab(page); + await expect + .poll(() => readFormValue(page, 'drivers.public_asset_disk'), { timeout: 20_000 }) + .toBe(original || 'none'); + }); + + // @scenario consumer=editor, disk_setting=none, e2e=ecommerce_settings_field, hook=unregistered, override=module_override, row_state=legacy_local_row + // @effects settings_catalog_includes_plugin_registered_disks + test('CKEditor5 설정 필드가 코어 설정 권한 없이도 카탈로그를 그린다', async ({ page }) => { + // core.settings.read 를 의도적으로 제외 — 카탈로그는 플러그인 설정 응답에 서버 + // 부착되므로, 코어 환경설정 API 권한이 없는 커스텀 역할에서도 선택지가 채워져야 + // 한다 (화면 권한과 카탈로그 권한의 표면 단일화 검증) + const pluginOnlyToken = issueToken('core.plugins.read', 'core.plugins.update'); + await authenticatePage(page, pluginOnlyToken); + await page.goto(CKEDITOR5_SETTINGS); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect + .poll( + async () => + await page.evaluate(() => { + const local = (window as any).G7Core?.state?.getLocal?.(); + return Array.isArray(local?.form?.available_public_asset_disks); + }), + { timeout: 20_000 }, + ) + .toBe(true); + + await expect(page.locator(PLUGIN_SELECT_ROOT)).toBeVisible({ timeout: 20_000 }); + + const labels = await openAndReadOptions(page, PLUGIN_SELECT_ROOT); + expect(labels[0]).toContain('코어 설정 따름'); + expect(labels.join('|')).toContain('사용 안 함'); + expect(labels.join('|')).toContain('Amazon S3'); + + await page.keyboard.press('Escape'); + }); +}); diff --git a/tests/Feature/Api/Admin/SettingsControllerDriverOptionsTest.php b/tests/Feature/Api/Admin/SettingsControllerDriverOptionsTest.php index 277cc154..86a9d79a 100644 --- a/tests/Feature/Api/Admin/SettingsControllerDriverOptionsTest.php +++ b/tests/Feature/Api/Admin/SettingsControllerDriverOptionsTest.php @@ -6,6 +6,8 @@ use App\Enums\ExtensionOwnerType; use App\Models\Permission; use App\Models\Role; use App\Models\User; +use App\Providers\SettingsServiceProvider; +use App\Repositories\JsonConfigRepository; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; use PHPUnit\Framework\Attributes\Test; @@ -57,7 +59,7 @@ class SettingsControllerDriverOptionsTest extends TestCase $permissionIds[] = $permission->id; } - $roleIdentifier = 'admin_test_' . uniqid(); + $roleIdentifier = 'admin_test_'.uniqid(); $adminRole = Role::create([ 'identifier' => $roleIdentifier, 'name' => json_encode(['ko' => '테스트 관리자', 'en' => 'Test Admin']), @@ -96,7 +98,7 @@ class SettingsControllerDriverOptionsTest extends TestCase private function authRequest(): static { return $this->withHeaders([ - 'Authorization' => 'Bearer ' . $this->token, + 'Authorization' => 'Bearer '.$this->token, 'Accept' => 'application/json', ]); } @@ -114,6 +116,7 @@ class SettingsControllerDriverOptionsTest extends TestCase 'data' => [ 'available_drivers' => [ 'storage', + 'public_asset', 'cache', 'session', 'queue', @@ -137,8 +140,8 @@ class SettingsControllerDriverOptionsTest extends TestCase $data = $response->json('data.available_drivers'); - // 7개 카테고리 존재 - $this->assertCount(7, $data); + // 8개 카테고리 존재 + $this->assertCount(8, $data); // 각 카테고리에 최소 1개 이상의 드라이버 존재 foreach ($data as $category => $drivers) { @@ -182,4 +185,118 @@ class SettingsControllerDriverOptionsTest extends TestCase $this->assertContains('mailgun', $ids); $this->assertContains('ses', $ids); } + + /** + * public_asset 카테고리에 코어 3종(none/public/s3)이 포함되는지 검증합니다. + * + * @effects settings_catalog_includes_plugin_registered_disks + */ + #[Test] + public function public_asset_drivers_include_core_options(): void + { + $response = $this->authRequest()->getJson('/api/admin/settings'); + + $drivers = $response->json('data.available_drivers.public_asset'); + $ids = array_column($drivers, 'id'); + + $this->assertSame(['none', 'public', 's3'], $ids); + } + + /** + * 카탈로그에 있는 공개 자산 디스크 저장이 성공하는지 검증합니다. + */ + #[Test] + public function saving_valid_public_asset_disk_succeeds(): void + { + $response = $this->authRequest()->postJson('/api/admin/settings', [ + '_tab' => 'drivers', + 'drivers' => $this->driversPayload(['public_asset_disk' => 'public']), + ]); + + $response->assertStatus(200); + + $saved = (new JsonConfigRepository)->getCategory('drivers'); + $this->assertSame('public', $saved['public_asset_disk'] ?? null); + } + + /** + * 저장 → drivers.json → core.storage.public_asset_disk 주입 사슬을 + * 한 테스트 안에서 종단으로 검증합니다. + * + * 주입 자동 실행은 testing 격리 가드(applyPublicAssetDiskConfig)가 막고 있으므로 + * (dev 공유 drivers.json 유입 차단), 가드를 통과한 뒤의 주입부만 실제 저장소로 + * 호출해 사슬을 잇습니다. mock 저장소를 쓰는 단위 테스트와 달리 이 테스트는 + * 저장 산출물(drivers.json)이 주입 입력이 되는 것까지 고정합니다. + * + * @effects global_setting_injects_core_config_with_none_normalized + */ + #[Test] + public function saved_public_asset_disk_reaches_core_config_through_injection(): void + { + config(['core.storage.public_asset_disk' => 'stale_value']); + + $this->authRequest()->postJson('/api/admin/settings', [ + '_tab' => 'drivers', + 'drivers' => $this->driversPayload(['public_asset_disk' => 'public']), + ])->assertStatus(200); + + $this->injectFromStoredSettings(); + + $this->assertSame('public', config('core.storage.public_asset_disk')); + + // 'none' 저장은 미설정('')으로 정규화되어 스트리밍으로 되돌아간다 + $this->authRequest()->postJson('/api/admin/settings', [ + '_tab' => 'drivers', + 'drivers' => $this->driversPayload(['public_asset_disk' => 'none']), + ])->assertStatus(200); + + $this->injectFromStoredSettings(); + + $this->assertSame('', config('core.storage.public_asset_disk')); + } + + /** + * 실제 저장 산출물(drivers.json)을 읽어 주입부를 호출합니다. + */ + private function injectFromStoredSettings(): void + { + $provider = new SettingsServiceProvider($this->app); + $method = new \ReflectionMethod($provider, 'injectPublicAssetDiskConfig'); + $method->invoke($provider, new JsonConfigRepository); + } + + /** + * 카탈로그에 없는 공개 자산 디스크 저장이 422 로 거부되는지 검증합니다. + * + * @effects invalid_disk_rejected_with_422 + */ + #[Test] + public function saving_unknown_public_asset_disk_fails_with_422(): void + { + $response = $this->authRequest()->postJson('/api/admin/settings', [ + '_tab' => 'drivers', + 'drivers' => $this->driversPayload(['public_asset_disk' => 'nonexistent_disk']), + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['drivers.public_asset_disk']); + } + + /** + * drivers 탭 저장에 필요한 최소 필수 필드 payload 를 반환합니다. + * + * @param array $overrides 덮어쓸 필드 + * @return array drivers payload + */ + private function driversPayload(array $overrides = []): array + { + return array_merge([ + 'storage_driver' => 'local', + 'cache_driver' => 'file', + 'session_driver' => 'file', + 'queue_driver' => 'sync', + 'log_driver' => 'daily', + 'log_level' => 'error', + ], $overrides); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 9e1b9fed..917c5b07 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -150,9 +150,34 @@ abstract class TestCase extends BaseTestCase // 누수를 차단한다 (프로덕션은 단일 부팅이라 무관 — 테스트 격리 전용). EnforceIdentityPolicyListener::resetDynamicSubscriptions(); + $this->purgeIdleDatabaseConnections(); + parent::tearDown(); } + /** + * 트랜잭션이 걸려 있지 않은 DB 연결을 모두 닫습니다. + * + * 트랜잭션 트레이트 없이 DB 를 쓴 테스트의 연결을 닫는다. RefreshDatabase 계열은 + * 롤백 후 스스로 disconnect 하지만, 순수 TestCase 는 아무도 닫지 않아 프로세스 내 + * 테스트 수만큼 연결이 누적된다 — DB 를 쓰는 테스트가 151개(max_connections)를 + * 넘는 클래스/스위트는 그 지점부터 전부 1040 Too many connections 로 실패한다. + * 트랜잭션 진행 중인 연결은 건드리지 않는다 (트레이트의 롤백 콜백이 처리). + */ + protected function purgeIdleDatabaseConnections(): void + { + if ($this->app === null) { + return; + } + + $db = $this->app['db']; + foreach ($db->getConnections() as $name => $connection) { + if ($connection->transactionLevel() === 0) { + $db->purge($name); + } + } + } + /** * 이 테스트가 허용하는 확장 목록을 계산합니다. * diff --git a/tests/Unit/Extension/Storage/CoreStorageDriverTest.php b/tests/Unit/Extension/Storage/CoreStorageDriverTest.php new file mode 100644 index 00000000..8275d733 --- /dev/null +++ b/tests/Unit/Extension/Storage/CoreStorageDriverTest.php @@ -0,0 +1,265 @@ + 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + } + + protected function tearDown(): void + { + HookManager::resetAll(); + Storage::disk('public')->deleteDirectory('core-url-test'); + + parent::tearDown(); + } + + #[Test] + public function it_can_put_get_and_delete_file(): void + { + $driver = new CoreStorageDriver('local'); + + $this->assertTrue($driver->put('temp', 'core-test/file.txt', 'content')); + $this->assertSame('content', $driver->get('temp', 'core-test/file.txt')); + $this->assertTrue($driver->exists('temp', 'core-test/file.txt')); + $this->assertTrue($driver->delete('temp', 'core-test/file.txt')); + $this->assertFalse($driver->exists('temp', 'core-test/file.txt')); + } + + /** + * @scenario consumer=product, disk_setting=none, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=legacy_local_row + * + * @effects url_null_for_disk_without_url_config, download_url_falls_back_to_api_path_when_direct_unavailable + */ + #[Test] + public function it_returns_null_url_for_disk_without_url_config(): void + { + // local 디스크는 filesystems.disks.local.url 미설정 — 직접 URL 불가 + $driver = new CoreStorageDriver('local'); + + $this->assertNull($driver->url('images', 'core-url-test/a.jpg')); + } + + /** + * @scenario consumer=product, disk_setting=public, e2e=drivers_tab_card, hook=unregistered, override=none_override, row_state=legacy_local_row + * + * @effects url_returned_for_public_and_url_configured_disk + */ + #[Test] + public function it_returns_url_for_public_disk(): void + { + // 기존 동작 보존 — public 디스크는 항상 직접 URL + $driver = new CoreStorageDriver('public'); + + $url = $driver->url('images', 'core-url-test/a.jpg'); + + $this->assertNotNull($url); + $this->assertStringContainsString('images/core-url-test/a.jpg', $url); + } + + /** + * @scenario consumer=category, disk_setting=fake_cdn, e2e=drivers_tab_card, hook=unregistered, override=follow_core, row_state=new_remote_row + * + * @effects url_returned_for_public_and_url_configured_disk + */ + #[Test] + public function it_returns_url_for_disk_with_url_config(): void + { + $driver = new CoreStorageDriver('fake_cdn'); + + $this->assertSame( + 'https://cdn.test/assets/images/core-url-test/a.jpg', + $driver->url('images', 'core-url-test/a.jpg') + ); + } + + /** + * @scenario consumer=category, disk_setting=s3_without_url, e2e=save_roundtrip, hook=unregistered, override=none_override, row_state=legacy_local_row + * + * @effects blank_url_config_defended + */ + #[Test] + public function it_returns_null_url_when_disk_url_config_is_blank(): void + { + // AWS_URL 빈값/공백 방어 — url 이 빈 문자열이거나 공백뿐이면 직접 URL 불가. + // 두 형태를 모두 고정한다: 구현이 trim() 단일 분기라 지금은 같은 경로를 타지만, + // 판정이 `!== ''` 로 좁혀지면 공백 케이스만 깨지므로 한쪽만으로는 검출되지 않는다. + foreach (['', ' '] as $blank) { + Config::set('filesystems.disks.fake_cdn.url', $blank); + $driver = new CoreStorageDriver('fake_cdn'); + + $this->assertNull( + $driver->url('images', 'core-url-test/a.jpg'), + sprintf('url 설정이 %s 이면 직접 URL 을 만들지 않아야 한다', var_export($blank, true)) + ); + } + } + + #[Test] + public function module_and_plugin_drivers_return_url_for_url_configured_disk(): void + { + $moduleDriver = new ModuleStorageDriver('test-module', 'fake_cdn'); + $pluginDriver = new PluginStorageDriver('test-plugin', 'fake_cdn'); + + $this->assertSame( + 'https://cdn.test/assets/test-module/images/products/1/a.jpg', + $moduleDriver->url('images', 'products/1/a.jpg') + ); + $this->assertSame( + 'https://cdn.test/assets/test-plugin/images/2026/01/01/b.png', + $pluginDriver->url('images', '2026/01/01/b.png') + ); + } + + /** + * @scenario consumer=editor, disk_setting=s3_without_url, e2e=drivers_tab_card, hook=supply, override=none_override, row_state=new_remote_row + * + * @effects filter_url_hook_always_fires_with_six_context_keys, hook_supply_modify_block_respected + */ + #[Test] + public function filter_url_hook_fires_even_when_url_is_null(): void + { + // 확정 #5 — null 일 때도 항상 발화 (훅이 URL 을 공급할 수 있다) + HookManager::addFilter('core.storage.filter_url', function ($url, array $context) { + $this->assertNull($url); + + return 'https://hook.test/supplied/'.$context['path']; + }); + + $driver = new CoreStorageDriver('local'); + + $this->assertSame( + 'https://hook.test/supplied/a.jpg', + $driver->url('images', 'a.jpg') + ); + } + + /** + * @scenario consumer=product, disk_setting=fake_cdn, e2e=ecommerce_settings_field, hook=supply, override=none_override, row_state=legacy_local_row + * + * @effects hook_supply_modify_block_respected + */ + #[Test] + public function filter_url_hook_can_modify_generated_url(): void + { + HookManager::addFilter('core.storage.filter_url', function ($url) { + return str_replace('https://cdn.test', 'https://cdn2.test', (string) $url); + }); + + $driver = new CoreStorageDriver('fake_cdn'); + + $this->assertSame( + 'https://cdn2.test/assets/images/a.jpg', + $driver->url('images', 'a.jpg') + ); + } + + /** + * @scenario consumer=review, disk_setting=fake_cdn, e2e=save_roundtrip, hook=block, override=none_override, row_state=legacy_local_row + * + * @effects hook_supply_modify_block_respected + */ + #[Test] + public function filter_url_hook_can_block_url_by_returning_empty(): void + { + // 반환이 빈문자열/비문자열이면 호출측 스트리밍 폴백 (null) + HookManager::addFilter('core.storage.filter_url', fn ($url) => ''); + + $driver = new CoreStorageDriver('fake_cdn'); + + $this->assertNull($driver->url('images', 'a.jpg')); + } + + /** + * @effects hook_supply_modify_block_respected + */ + #[Test] + public function filter_url_hook_non_string_or_blank_returns_fall_back_to_null(): void + { + // 훅이 계약 밖의 값을 돌려줘도 예외 없이 스트리밍 폴백 (방어 계약) + foreach ([null, false, ['url' => 'https://x.test/a.jpg'], ' '] as $badReturn) { + HookManager::resetAll(); + HookManager::addFilter('core.storage.filter_url', fn ($url) => $badReturn); + + $driver = new CoreStorageDriver('fake_cdn'); + + $this->assertNull( + $driver->url('images', 'a.jpg'), + '훅 반환값 '.var_export($badReturn, true).' 은 null 폴백이어야 한다' + ); + } + } + + /** + * @scenario consumer=review, disk_setting=none, e2e=drivers_tab_card, hook=supply, override=none_override, row_state=legacy_local_row + * + * @effects filter_url_hook_always_fires_with_six_context_keys + */ + #[Test] + public function filter_url_hook_receives_six_context_keys(): void + { + $captured = null; + HookManager::addFilter('core.storage.filter_url', function ($url, array $context) use (&$captured) { + $captured = $context; + + return $url; + }); + + (new CoreStorageDriver('fake_cdn'))->url('images', 'sub/a.jpg'); + + $this->assertSame([ + 'scope' => 'core', + 'identifier' => null, + 'disk' => 'fake_cdn', + 'category' => 'images', + 'path' => 'sub/a.jpg', + 'full_path' => 'images/sub/a.jpg', + ], $captured); + } + + #[Test] + public function filter_url_hook_context_identifies_module_and_plugin_scope(): void + { + $contexts = []; + HookManager::addFilter('core.storage.filter_url', function ($url, array $context) use (&$contexts) { + $contexts[] = $context; + + return $url; + }); + + (new ModuleStorageDriver('test-module', 'local'))->url('images', 'a.jpg'); + (new PluginStorageDriver('test-plugin', 'local'))->url('images', 'b.jpg'); + + $this->assertSame('module', $contexts[0]['scope']); + $this->assertSame('test-module', $contexts[0]['identifier']); + $this->assertSame('test-module/images/a.jpg', $contexts[0]['full_path']); + $this->assertSame('plugin', $contexts[1]['scope']); + $this->assertSame('test-plugin', $contexts[1]['identifier']); + $this->assertSame('test-plugin/images/b.jpg', $contexts[1]['full_path']); + } +} diff --git a/tests/Unit/Extension/Storage/ModuleStorageDriverTest.php b/tests/Unit/Extension/Storage/ModuleStorageDriverTest.php index a5261c7f..dd666135 100644 --- a/tests/Unit/Extension/Storage/ModuleStorageDriverTest.php +++ b/tests/Unit/Extension/Storage/ModuleStorageDriverTest.php @@ -2,11 +2,14 @@ namespace Tests\Unit\Extension\Storage; +use App\Extension\HookManager; use App\Extension\Storage\ModuleStorageDriver; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Storage; -use Tests\TestCase; use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Tests\TestCase; /** * ModuleStorageDriver 단위 테스트 @@ -32,6 +35,7 @@ class ModuleStorageDriverTest extends TestCase { // 테스트 데이터 정리 Storage::disk('modules')->deleteDirectory('test-module'); + HookManager::resetAll(); parent::tearDown(); } @@ -189,6 +193,74 @@ class ModuleStorageDriverTest extends TestCase Storage::disk('public')->deleteDirectory('test-module'); } + /** + * url 이 설정된 디스크(S3+CDN 등)면 모듈 드라이버도 직접 URL 을 돌려준다. + * + * @effects url_returned_for_public_and_url_configured_disk + */ + #[Test] + public function it_returns_direct_url_for_url_configured_disk(): void + { + Config::set('filesystems.disks.fake_cdn', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + + $cdnDriver = new ModuleStorageDriver('test-module', 'fake_cdn'); + + $this->assertSame( + 'https://cdn.test/assets/test-module/images/test.jpg', + $cdnDriver->url('images', 'test.jpg') + ); + } + + /** + * url 값이 빈 문자열/공백이면 직접 URL 로 보지 않는다 (AWS_URL 미설정 방어). + * + * @effects blank_url_config_defended + */ + #[Test] + public function it_returns_null_when_disk_url_is_blank(): void + { + Config::set('filesystems.disks.fake_cdn', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => ' ', + ]); + + $this->assertNull((new ModuleStorageDriver('test-module', 'fake_cdn'))->url('images', 'test.jpg')); + } + + /** + * 모듈 드라이버의 `core.storage.filter_url` 컨텍스트는 scope=module + + * 확장 식별자를 싣는다 (코어/플러그인과 구분되는 축). + * + * @effects filter_url_hook_always_fires_with_six_context_keys + */ + #[Test] + public function it_passes_module_scope_and_identifier_to_url_filter_hook(): void + { + $captured = null; + + HookManager::addFilter('core.storage.filter_url', function ($url, $context) use (&$captured) { + $captured = $context; + + return $url; + }); + + (new ModuleStorageDriver('test-module', 'modules'))->url('images', 'sub/a.jpg'); + + $this->assertSame([ + 'scope' => 'module', + 'identifier' => 'test-module', + 'disk' => 'modules', + 'category' => 'images', + 'path' => 'sub/a.jpg', + 'full_path' => 'test-module/images/sub/a.jpg', + ], $captured); + } + #[Test] public function it_uses_correct_path_pattern(): void { @@ -218,7 +290,7 @@ class ModuleStorageDriverTest extends TestCase ]); // Then: StreamedResponse 반환 - $this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $response); + $this->assertInstanceOf(StreamedResponse::class, $response); } #[Test] diff --git a/tests/Unit/Extension/Storage/PluginStorageDriverTest.php b/tests/Unit/Extension/Storage/PluginStorageDriverTest.php index c400ce86..6f012f35 100644 --- a/tests/Unit/Extension/Storage/PluginStorageDriverTest.php +++ b/tests/Unit/Extension/Storage/PluginStorageDriverTest.php @@ -2,11 +2,14 @@ namespace Tests\Unit\Extension\Storage; +use App\Extension\HookManager; use App\Extension\Storage\PluginStorageDriver; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Storage; -use Tests\TestCase; use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\HttpFoundation\StreamedResponse; +use Tests\TestCase; /** * PluginStorageDriver 단위 테스트 @@ -32,6 +35,7 @@ class PluginStorageDriverTest extends TestCase { // 테스트 데이터 정리 Storage::disk('plugins')->deleteDirectory('test-plugin'); + HookManager::resetAll(); parent::tearDown(); } @@ -189,6 +193,74 @@ class PluginStorageDriverTest extends TestCase Storage::disk('public')->deleteDirectory('test-plugin'); } + /** + * url 이 설정된 디스크(S3+CDN 등)면 플러그인 드라이버도 직접 URL 을 돌려준다. + * + * @effects url_returned_for_public_and_url_configured_disk + */ + #[Test] + public function it_returns_direct_url_for_url_configured_disk(): void + { + Config::set('filesystems.disks.fake_cdn', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + + $cdnDriver = new PluginStorageDriver('test-plugin', 'fake_cdn'); + + $this->assertSame( + 'https://cdn.test/assets/test-plugin/images/test.jpg', + $cdnDriver->url('images', 'test.jpg') + ); + } + + /** + * url 값이 빈 문자열/공백이면 직접 URL 로 보지 않는다 (AWS_URL 미설정 방어). + * + * @effects blank_url_config_defended + */ + #[Test] + public function it_returns_null_when_disk_url_is_blank(): void + { + Config::set('filesystems.disks.fake_cdn', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => ' ', + ]); + + $this->assertNull((new PluginStorageDriver('test-plugin', 'fake_cdn'))->url('images', 'test.jpg')); + } + + /** + * 플러그인 드라이버의 `core.storage.filter_url` 컨텍스트는 scope=plugin + + * 확장 식별자를 싣는다 (코어/모듈과 구분되는 축). + * + * @effects filter_url_hook_always_fires_with_six_context_keys + */ + #[Test] + public function it_passes_plugin_scope_and_identifier_to_url_filter_hook(): void + { + $captured = null; + + HookManager::addFilter('core.storage.filter_url', function ($url, $context) use (&$captured) { + $captured = $context; + + return $url; + }); + + (new PluginStorageDriver('test-plugin', 'plugins'))->url('images', 'sub/a.jpg'); + + $this->assertSame([ + 'scope' => 'plugin', + 'identifier' => 'test-plugin', + 'disk' => 'plugins', + 'category' => 'images', + 'path' => 'sub/a.jpg', + 'full_path' => 'test-plugin/images/sub/a.jpg', + ], $captured); + } + #[Test] public function it_uses_correct_path_pattern(): void { @@ -218,7 +290,7 @@ class PluginStorageDriverTest extends TestCase ]); // Then: StreamedResponse 반환 - $this->assertInstanceOf(\Symfony\Component\HttpFoundation\StreamedResponse::class, $response); + $this->assertInstanceOf(StreamedResponse::class, $response); } #[Test] diff --git a/tests/Unit/Extension/Storage/StorageCategoryDiskTest.php b/tests/Unit/Extension/Storage/StorageCategoryDiskTest.php new file mode 100644 index 00000000..71961ebc --- /dev/null +++ b/tests/Unit/Extension/Storage/StorageCategoryDiskTest.php @@ -0,0 +1,212 @@ + 코어 전역 > 미설정)와 + * 고아 디스크 폴백을 검증합니다. + */ +class StorageCategoryDiskTest extends TestCase +{ + protected function setUp(): void + { + parent::setUp(); + + Config::set('filesystems.disks.fake_cdn', [ + 'driver' => 'local', + 'root' => storage_path('framework/testing/disks/fake_cdn'), + 'url' => 'https://cdn.test/assets', + ]); + Config::set('core.storage.public_asset_disk', ''); + } + + /** + * resolvePublicAssetDisk 를 노출한 테스트 모듈 더블을 생성합니다. + * + * @return AbstractModule&object{exposedResolvePublicAssetDisk: callable} 테스트 모듈 + */ + private function makeModule() + { + return new class extends AbstractModule + { + public function getName(): array + { + return ['ko' => '테스트', 'en' => 'Test']; + } + + public function getVersion(): string + { + return '1.0.0'; + } + + public function getDescription(): array + { + return ['ko' => '테스트', 'en' => 'Test']; + } + + public function exposedResolvePublicAssetDisk(?string $override = null): ?string + { + return $this->resolvePublicAssetDisk($override); + } + }; + } + + /** + * 테스트 플러그인 더블을 생성합니다. + * + * @return AbstractPlugin 테스트 플러그인 + */ + private function makePlugin(): AbstractPlugin + { + return new class extends AbstractPlugin + { + public function getName(): array + { + return ['ko' => '테스트', 'en' => 'Test']; + } + + public function getVersion(): string + { + return '1.0.0'; + } + + public function getDescription(): array + { + return ['ko' => '테스트', 'en' => 'Test']; + } + }; + } + + #[Test] + public function get_storage_disk_for_defaults_to_base_disk(): void + { + $module = $this->makeModule(); + $plugin = $this->makePlugin(); + + // 오버라이드 없는 기본 구현 — 모든 카테고리가 기본 디스크 (현행 100% 보존) + $this->assertSame($module->getStorageDisk(), $module->getStorageDiskFor('images')); + $this->assertSame($module->getStorageDisk(), $module->getStorageDiskFor('settings')); + $this->assertSame($plugin->getStorageDisk(), $plugin->getStorageDiskFor('images')); + $this->assertSame($plugin->getStorageDisk(), $plugin->getStorageDiskFor('settings')); + } + + #[Test] + public function get_storage_for_reuses_base_instance_when_disk_matches(): void + { + $module = $this->makeModule(); + + $this->assertSame($module->getStorage(), $module->getStorageFor('images')); + } + + /** + * @scenario consumer=review, disk_setting=public, e2e=drivers_tab_card, hook=block, override=module_override, row_state=new_remote_row + */ + #[Test] + public function get_storage_for_memoizes_per_disk(): void + { + $module = new class extends AbstractModule + { + public function getName(): array + { + return ['ko' => '테스트', 'en' => 'Test']; + } + + public function getVersion(): string + { + return '1.0.0'; + } + + public function getDescription(): array + { + return ['ko' => '테스트', 'en' => 'Test']; + } + + public function getStorageDiskFor(string $category): string + { + return $category === 'images' ? 'fake_cdn' : $this->getStorageDisk(); + } + }; + + $imagesStorage = $module->getStorageFor('images'); + + $this->assertSame('fake_cdn', $imagesStorage->getDisk()); + $this->assertNotSame($module->getStorage(), $imagesStorage); + // 디스크 단위 memoize — 반복 호출이 같은 인스턴스 반환 + $this->assertSame($imagesStorage, $module->getStorageFor('images')); + // 기본 디스크 카테고리는 기본 인스턴스 재사용 + $this->assertSame($module->getStorage(), $module->getStorageFor('settings')); + } + + /** + * @scenario consumer=product, disk_setting=none, e2e=drivers_tab_card, hook=unregistered, override=module_override, row_state=legacy_local_row + * + * @effects none_override_forces_streaming_over_global + */ + #[Test] + public function resolve_public_asset_disk_prefers_override_over_global(): void + { + Config::set('core.storage.public_asset_disk', 'public'); + $module = $this->makeModule(); + + $this->assertSame('fake_cdn', $module->exposedResolvePublicAssetDisk('fake_cdn')); + } + + /** + * @scenario consumer=editor, disk_setting=none, e2e=save_roundtrip, hook=supply, override=follow_core, row_state=new_remote_row + * + * @effects none_override_forces_streaming_over_global + */ + #[Test] + public function resolve_public_asset_disk_falls_back_to_global_when_override_empty(): void + { + Config::set('core.storage.public_asset_disk', 'fake_cdn'); + $module = $this->makeModule(); + + $this->assertSame('fake_cdn', $module->exposedResolvePublicAssetDisk('')); + $this->assertSame('fake_cdn', $module->exposedResolvePublicAssetDisk(null)); + } + + #[Test] + public function resolve_public_asset_disk_returns_null_when_unset(): void + { + $module = $this->makeModule(); + + // 전역 미설정 + 오버라이드 없음 → 스트리밍 유지 + $this->assertNull($module->exposedResolvePublicAssetDisk()); + } + + #[Test] + public function resolve_public_asset_disk_treats_none_as_streaming(): void + { + Config::set('core.storage.public_asset_disk', 'fake_cdn'); + $module = $this->makeModule(); + + // 'none' 오버라이드 = 전역이 CDN 이어도 이 확장은 스트리밍 강제 + $this->assertNull($module->exposedResolvePublicAssetDisk('none')); + } + + /** + * @scenario consumer=product, disk_setting=s3_without_url, e2e=drivers_tab_card, hook=block, override=module_override, row_state=new_remote_row + * + * @effects orphan_disk_falls_back_to_streaming + */ + #[Test] + public function resolve_public_asset_disk_falls_back_on_orphan_disk(): void + { + // 플러그인 비활성화로 config 에서 사라진 디스크 → 스트리밍 자동 폴백 + Config::set('core.storage.public_asset_disk', 'vanished_plugin_disk'); + $module = $this->makeModule(); + + $this->assertNull($module->exposedResolvePublicAssetDisk()); + $this->assertNull($module->exposedResolvePublicAssetDisk('vanished_plugin_disk')); + } +} diff --git a/tests/Unit/Models/AttachmentUrlTest.php b/tests/Unit/Models/AttachmentUrlTest.php new file mode 100644 index 00000000..12963cbf --- /dev/null +++ b/tests/Unit/Models/AttachmentUrlTest.php @@ -0,0 +1,33 @@ +assertSame('/api/attachment/abc123def456', Attachment::urlForHash('abc123def456')); + } + + #[Test] + public function download_url_accessor_delegates_to_url_for_hash(): void + { + $attachment = new Attachment; + $attachment->hash = 'abc123def456'; + + $this->assertSame('/api/attachment/abc123def456', $attachment->download_url); + } +} diff --git a/tests/Unit/Providers/SettingsServiceProviderPublicAssetDiskTest.php b/tests/Unit/Providers/SettingsServiceProviderPublicAssetDiskTest.php new file mode 100644 index 00000000..f38aadb5 --- /dev/null +++ b/tests/Unit/Providers/SettingsServiceProviderPublicAssetDiskTest.php @@ -0,0 +1,115 @@ +createMock(JsonConfigRepository::class); + $configRepository->method('getCategory') + ->with('drivers') + ->willReturn($driverSettings); + + $provider = new SettingsServiceProvider($this->app); + $reflection = new ReflectionMethod($provider, $method); + $reflection->invoke($provider, $configRepository); + } + + /** + * 저장값이 core.storage.public_asset_disk 로 주입되는지 테스트합니다. + */ + public function test_stored_disk_injected_into_core_config(): void + { + Config::set('core.storage.public_asset_disk', ''); + + $this->callWithDriverSettings('injectPublicAssetDiskConfig', [ + 'public_asset_disk' => 'fake_cdn', + ]); + + $this->assertSame('fake_cdn', config('core.storage.public_asset_disk')); + } + + /** + * 'none'(스트리밍 유지 선택)이 미설정('')으로 정규화되는지 테스트합니다. + */ + public function test_none_sentinel_normalized_to_empty(): void + { + Config::set('core.storage.public_asset_disk', 'stale_value'); + + $this->callWithDriverSettings('injectPublicAssetDiskConfig', [ + 'public_asset_disk' => 'none', + ]); + + $this->assertSame('', config('core.storage.public_asset_disk')); + } + + /** + * 저장 키 부재 시 미설정('')으로 주입되는지 테스트합니다. + */ + public function test_missing_key_injected_as_empty(): void + { + Config::set('core.storage.public_asset_disk', 'stale_value'); + + $this->callWithDriverSettings('injectPublicAssetDiskConfig', []); + + $this->assertSame('', config('core.storage.public_asset_disk')); + } + + /** + * 빈 문자열 저장값이 그대로 미설정('')인지 테스트합니다. + */ + public function test_blank_value_injected_as_empty(): void + { + Config::set('core.storage.public_asset_disk', 'stale_value'); + + $this->callWithDriverSettings('injectPublicAssetDiskConfig', [ + 'public_asset_disk' => '', + ]); + + $this->assertSame('', config('core.storage.public_asset_disk')); + } + + /** + * testing 환경에서 가드가 주입을 차단하는지 테스트합니다 (테스트 격리). + * + * 이 테스트 자체가 testing 환경에서 실행되므로, 가드 메서드 + * (applyPublicAssetDiskConfig)를 호출해도 dev 공유 drivers.json 값이 + * 흘러들지 않아야 합니다. + */ + public function test_guard_skips_injection_in_testing_env(): void + { + Config::set('core.storage.public_asset_disk', ''); + + $this->callWithDriverSettings('applyPublicAssetDiskConfig', [ + 'public_asset_disk' => 'fake_cdn', + ]); + + $this->assertSame('', config('core.storage.public_asset_disk')); + } +} diff --git a/tests/Unit/Seo/ComponentHtmlMapperTest.php b/tests/Unit/Seo/ComponentHtmlMapperTest.php index ef3690da..9612c8d5 100644 --- a/tests/Unit/Seo/ComponentHtmlMapperTest.php +++ b/tests/Unit/Seo/ComponentHtmlMapperTest.php @@ -2202,6 +2202,8 @@ class ComponentHtmlMapperTest extends TestCase /** * url 필드가 비어있을 때 download_url로 fallback합니다. + * + * @effects download_url_falls_back_to_api_path_when_direct_unavailable */ public function test_image_gallery_download_url_fallback(): void { @@ -2242,6 +2244,9 @@ class ComponentHtmlMapperTest extends TestCase /** * url 필드가 존재하면 download_url보다 우선합니다. + * (공개 자산 디스크 설정 시 download_url 이 직접 CDN URL 이어도 동일 경로로 소비) + * + * @effects download_url_falls_back_to_api_path_when_direct_unavailable */ public function test_image_gallery_url_takes_priority(): void { @@ -2273,6 +2278,97 @@ class ComponentHtmlMapperTest extends TestCase $this->assertStringNotContainsString('product-image/abc123', $html); } + /** + * download_url 이 절대 CDN URL 일 때(공개 자산 디스크 설정) iterate 가드가 + * url() 이중 접두 없이 그대로 통과시킵니다. + * + * 공개#100 이후 download_url 은 상대 API 경로만이 아니라 절대 CDN URL 일 수 + * 있다 — 가드가 http 프리픽스를 오판하면 `http://origin/https://cdn...` 처럼 + * 깨진 src 가 봇 화면에 나간다. + * + * @effects seo_guard_passes_absolute_cdn_url_unmodified + */ + public function test_image_gallery_absolute_cdn_download_url_passes_unmodified(): void + { + $components = [ + [ + 'name' => 'ProductImageViewer', + 'props' => [ + 'images' => '{{product.images}}', + ], + ], + ]; + + $context = [ + 'product' => [ + 'images' => [ + [ + 'url' => '', + 'download_url' => 'https://cdn.test/assets/sirsoft-ecommerce/images/products/1/a.jpg', + 'alt_text_current' => 'CDN 이미지', + ], + ], + ], + ]; + + $html = $this->render($components, $context); + + $this->assertStringContainsString( + 'src="https://cdn.test/assets/sirsoft-ecommerce/images/products/1/a.jpg"', + $html, + ); + // url() 이중 접두 회귀 방어 — 절대 URL 앞에 오리진이 덧붙으면 안 된다 + $this->assertStringNotContainsString('/https://cdn.test/', $html); + } + + /** + * ProductCard(fields attrs 가드)의 thumbnail_url 이 절대 CDN URL 일 때 + * 그대로 통과하고, 상대 경로일 때만 절대화됩니다. + * + * @effects seo_guard_passes_absolute_cdn_url_unmodified + */ + public function test_product_card_thumbnail_absolute_cdn_url_passes_unmodified(): void + { + $components = [ + [ + 'type' => 'composite', + 'name' => 'ProductCard', + 'props' => ['product' => '{{product}}'], + ], + ]; + + $context = [ + 'product' => [ + 'id' => 7, + 'name' => 'CDN Thumb', + 'name_localized' => 'CDN 썸네일 상품', + 'thumbnail_url' => 'https://cdn.test/assets/sirsoft-ecommerce/images/products/7/t.jpg', + 'selling_price_formatted' => '1,000원', + 'list_price_formatted' => '1,000원', + 'discount_rate' => 0, + 'primary_category' => '', + 'brand_name' => '', + 'sales_status' => '', + 'sales_status_label' => '', + 'labels' => [], + ], + ]; + + $html = $this->render($components, $context); + + $this->assertStringContainsString( + 'src="https://cdn.test/assets/sirsoft-ecommerce/images/products/7/t.jpg"', + $html, + ); + $this->assertStringNotContainsString('/https://cdn.test/', $html); + + // 대조군 — 상대 경로는 여전히 절대화된다 (가드의 양방향 동작 고정) + $context['product']['thumbnail_url'] = '/storage/products/rel.jpg'; + $relativeHtml = $this->render($components, $context); + + $this->assertStringContainsString('src="'.url('/storage/products/rel.jpg').'"', $relativeHtml); + } + /** * alt_text_current 필드로 alt 속성이 렌더링됩니다. */ diff --git a/tests/Unit/Services/DriverExtensionIntegrationTest.php b/tests/Unit/Services/DriverExtensionIntegrationTest.php index c31629e6..3f6f105e 100644 --- a/tests/Unit/Services/DriverExtensionIntegrationTest.php +++ b/tests/Unit/Services/DriverExtensionIntegrationTest.php @@ -27,7 +27,7 @@ class DriverExtensionIntegrationTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->driverRegistry = new DriverRegistryService(); + $this->driverRegistry = new DriverRegistryService; // 테스트용 디렉토리 정리 Storage::disk('modules')->deleteDirectory('test-driver-module'); @@ -51,12 +51,14 @@ class DriverExtensionIntegrationTest extends TestCase // ======================================================================== /** - * 7개 카테고리 모두에 플러그인 드라이버를 등록하면 available 목록에 포함되는지 검증합니다. + * 8개 카테고리 모두에 플러그인 드라이버를 등록하면 available 목록에 포함되는지 검증합니다. + * + * @effects settings_catalog_includes_plugin_registered_disks */ #[Test] public function plugin_can_register_drivers_for_all_categories(): void { - $categories = ['storage', 'cache', 'session', 'queue', 'log', 'websocket', 'mail']; + $categories = ['storage', 'public_asset', 'cache', 'session', 'queue', 'log', 'websocket', 'mail']; foreach ($categories as $category) { HookManager::addFilter( @@ -568,6 +570,7 @@ class DriverExtensionIntegrationTest extends TestCase { $expectedDefaults = [ 'storage' => ['local', 'filesystems.default'], + 'public_asset' => ['none', 'core.storage.public_asset_disk'], 'cache' => ['file', 'cache.default'], 'session' => ['database', 'session.driver'], 'queue' => ['database', 'queue.default'], diff --git a/tests/Unit/Services/DriverRegistryServiceTest.php b/tests/Unit/Services/DriverRegistryServiceTest.php index fb46df27..697c926d 100644 --- a/tests/Unit/Services/DriverRegistryServiceTest.php +++ b/tests/Unit/Services/DriverRegistryServiceTest.php @@ -24,7 +24,7 @@ class DriverRegistryServiceTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->service = new DriverRegistryService(); + $this->service = new DriverRegistryService; } protected function tearDown(): void @@ -35,16 +35,18 @@ class DriverRegistryServiceTest extends TestCase } /** - * 코어 드라이버가 7개 카테고리를 모두 포함하는지 검증합니다. + * 코어 드라이버가 8개 카테고리를 모두 포함하는지 검증합니다. + * + * @effects settings_catalog_includes_plugin_registered_disks */ #[Test] - public function it_returns_all_seven_categories(): void + public function it_returns_all_eight_categories(): void { $categories = $this->service->getCategories(); - $this->assertCount(7, $categories); + $this->assertCount(8, $categories); $this->assertEquals( - ['storage', 'cache', 'session', 'queue', 'log', 'websocket', 'mail'], + ['storage', 'public_asset', 'cache', 'session', 'queue', 'log', 'websocket', 'mail'], $categories ); } @@ -76,6 +78,40 @@ class DriverRegistryServiceTest extends TestCase $this->assertContains('ses', $driverIds); } + /** + * 공개 자산 카테고리 코어 드라이버를 검증합니다. + * + * 화면 Select 가 그대로 그리는 목록이므로 순서까지 고정합니다 + * ('none' 이 선두가 아니면 기본 선택값이 바뀐다). + * + * @effects settings_catalog_includes_plugin_registered_disks + */ + #[Test] + public function it_returns_public_asset_core_drivers_in_order(): void + { + $drivers = $this->service->getAvailableDrivers('public_asset'); + + $this->assertSame(['none', 'public', 's3'], array_column($drivers, 'id')); + } + + /** + * 공개 자산 디스크의 사용 가능 여부 판별을 검증합니다. + * + * SaveSettingsRequest 의 closure rule 이 이 판정에 의존하므로, + * 미등록 값이 true 로 새면 422 게이트가 통째로 무력화됩니다. + * + * @effects invalid_disk_rejected_with_422 + */ + #[Test] + public function it_reports_public_asset_driver_availability(): void + { + $this->assertTrue($this->service->isDriverAvailable('public_asset', 'none')); + $this->assertTrue($this->service->isDriverAvailable('public_asset', 'public')); + $this->assertTrue($this->service->isDriverAvailable('public_asset', 's3')); + + $this->assertFalse($this->service->isDriverAvailable('public_asset', 'nonexistent_disk')); + } + /** * 모든 카테고리의 드라이버를 한 번에 반환하는지 검증합니다. */ @@ -85,13 +121,14 @@ class DriverRegistryServiceTest extends TestCase $all = $this->service->getAllAvailableDrivers(); $this->assertArrayHasKey('storage', $all); + $this->assertArrayHasKey('public_asset', $all); $this->assertArrayHasKey('cache', $all); $this->assertArrayHasKey('session', $all); $this->assertArrayHasKey('queue', $all); $this->assertArrayHasKey('log', $all); $this->assertArrayHasKey('websocket', $all); $this->assertArrayHasKey('mail', $all); - $this->assertCount(7, $all); + $this->assertCount(8, $all); } /** @@ -137,6 +174,7 @@ class DriverRegistryServiceTest extends TestCase public function it_returns_correct_default_drivers(): void { $this->assertEquals('local', $this->service->getDefaultDriver('storage')); + $this->assertEquals('none', $this->service->getDefaultDriver('public_asset')); $this->assertEquals('file', $this->service->getDefaultDriver('cache')); $this->assertEquals('database', $this->service->getDefaultDriver('session')); $this->assertEquals('database', $this->service->getDefaultDriver('queue')); diff --git a/tests/Unit/TestCaseConnectionPurgeTest.php b/tests/Unit/TestCaseConnectionPurgeTest.php new file mode 100644 index 00000000..f62d12d2 --- /dev/null +++ b/tests/Unit/TestCaseConnectionPurgeTest.php @@ -0,0 +1,49 @@ +assertNotEmpty($this->app['db']->getConnections(), '쿼리 후 연결이 열려 있어야 합니다'); + + $this->purgeIdleDatabaseConnections(); + + $this->assertSame([], $this->app['db']->getConnections(), '유휴 연결은 모두 닫혀야 합니다'); + } + + #[Test] + public function transacting_connection_survives_purge(): void + { + DB::beginTransaction(); + + try { + $this->purgeIdleDatabaseConnections(); + + // 트랜잭션 진행 중 연결은 보존 — 트레이트의 롤백 콜백이 담당한다 + $this->assertArrayHasKey( + DB::getDefaultConnection(), + $this->app['db']->getConnections(), + '트랜잭션 진행 중인 연결은 purge 대상이 아니어야 합니다', + ); + $this->assertSame(1, DB::connection()->transactionLevel()); + } finally { + DB::rollBack(); + } + } +} diff --git a/tests/scenarios/public-asset-cdn.yaml b/tests/scenarios/public-asset-cdn.yaml new file mode 100644 index 00000000..01182153 --- /dev/null +++ b/tests/scenarios/public-asset-cdn.yaml @@ -0,0 +1,83 @@ +feature: 공개 자산 직접 URL(CDN) 서빙 (공개#100) + +description: | + StorageInterface::url() 의 public 하드코딩을 완화하고 `core.storage.filter_url` + 훅을 신설해, 완전 공개 자산(상품/카테고리/리뷰/에디터 이미지)의 직접 URL(CDN) + 서빙을 옵트인으로 연다. 미설정 시 기존 PHP 스트리밍 100% 보존. + + 결정 사슬: + - url(): public 디스크 또는 filesystems.disks.{disk}.url 설정 디스크 → 직접 URL, + 그 외 null. 결과는 디스크 무관 core.storage.filter_url 훅 상시 통과(공급/수정/차단). + - 디스크 결정: 확장 개별 설정(public_asset_disk) > 코어 전역 + (drivers.public_asset_disk → core.storage.public_asset_disk) > 미설정(스트리밍). + 'none'/고아 디스크는 스트리밍 폴백. + - 소비처: 행 disk 로 url() 시도 → null 이면 기존 API 서빙 경로 폴백. + 서빙/삭제/이동은 행 disk 기준(혼재 운용). + - 권한 검사가 걸린 첨부(board/page/코어)는 배선 자체가 없음 — 직접 URL 이 + 권한을 우회하므로 스트리밍 유지가 보안 전제. + +coverage_strategy: pairwise + +axes: + disk_setting: [none, public, fake_cdn, s3_without_url] + consumer: [product, category, review, editor] + row_state: [legacy_local_row, new_remote_row] + override: [follow_core, module_override, none_override] + hook: [unregistered, supply, block] + e2e: [drivers_tab_card, ecommerce_settings_field, save_roundtrip] + +effects: + - url_null_for_disk_without_url_config + - url_returned_for_public_and_url_configured_disk + - blank_url_config_defended + - filter_url_hook_always_fires_with_six_context_keys + - hook_supply_modify_block_respected + - download_url_falls_back_to_api_path_when_direct_unavailable + - mixed_rows_resolve_per_row_disk + - serve_and_delete_follow_row_disk + - orphan_disk_falls_back_to_streaming + - none_override_forces_streaming_over_global + - settings_catalog_includes_plugin_registered_disks + - invalid_disk_rejected_with_422 + - board_response_strings_unchanged + - settings_load_recursion_free + - global_setting_injects_core_config_with_none_normalized + - seo_guard_passes_absolute_cdn_url_unmodified + +test_files: + - tests/Unit/Extension/Storage/CoreStorageDriverTest.php + - tests/Unit/Extension/Storage/ModuleStorageDriverTest.php + - tests/Unit/Extension/Storage/PluginStorageDriverTest.php + - tests/Unit/Extension/Storage/StorageCategoryDiskTest.php + - tests/Unit/Services/DriverRegistryServiceTest.php + - tests/Unit/Services/DriverExtensionIntegrationTest.php + - tests/Unit/Models/AttachmentUrlTest.php + - tests/Unit/Seo/ComponentHtmlMapperTest.php + - tests/Feature/Api/Admin/SettingsControllerDriverOptionsTest.php + - tests/Unit/Providers/SettingsServiceProviderPublicAssetDiskTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Models/HasDirectAssetUrlTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductImageServiceRowDiskTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/CategoryImageServiceRowDiskTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductReviewImageServiceRowDiskTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/StorageDiskRecursionTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/ProductServiceDetailImageUrlTest.php + - modules/_bundled/sirsoft-board/tests/Unit/AttachmentPreviewUrlTest.php + - modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceThumbnailUrlTest.php + - modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositoryPopularAvatarTest.php + - plugins/_bundled/sirsoft-ckeditor5/tests/Unit/Ckeditor5ImageUploadUrlTest.php + - plugins/_bundled/sirsoft-ckeditor5/tests/Unit/ImageServeServiceRowDiskTest.php + - plugins/_bundled/sirsoft-ckeditor5/tests/Unit/StorageDiskRecursionTest.php + - plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsCatalogAttachTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Unit/Http/Requests/StoreEcommerceSettingsRequestPublicAssetDiskTest.php + - plugins/_bundled/sirsoft-ckeditor5/tests/Unit/PluginSettingsPublicAssetDiskValidationTest.php + - templates/_bundled/sirsoft-admin_basic/tests/Playwright/specs/public-asset-storage.spec.ts + - modules/_bundled/sirsoft-ecommerce/tests/Playwright/specs/admin/public-asset-disk-override.spec.ts + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-public-asset-storage.test.tsx + - modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/adminEcommerceSettingsPublicAssetDisk.test.tsx + - plugins/_bundled/sirsoft-ckeditor5/resources/js/__tests__/layouts/pluginSettingsPublicAssetDisk.test.tsx + +rules_layer_coverage: + - rule: no-storage-disk-direct + coverage: 소비처는 StorageInterface 경유 — Storage facade 직접 호출 도입 없음 + - rule: repository-interface-injection + coverage: 서비스 시그니처 무변경 — contextual binding 만 카테고리 디스크로 교체