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
+
-
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 —
+
-
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 만 카테고리 디스크로 교체