Merge pull request from gnuboard:HeuJung/issue562

feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
This commit is contained in:
정정홍 authored and GitHub committed 2026-08-14 08:09:02 +09:00
commit a5a7caf8ba
125 files changed
+5913 -203

No files matched your search

+6
View File
@@ -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
+1 -1
View File
@@ -515,9 +515,9 @@ cp .env.example .env
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
+1 -1
View File
@@ -531,9 +531,9 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
@@ -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;
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
+11 -7
View File
@@ -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;
}
@@ -40,6 +40,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
*/
protected array $storageServices = [];
/**
* 카테고리별 StorageInterface 가 필요한 서비스 클래스 매핑 (클래스 ⇒ 카테고리).
*
* 등록된 클래스에는 getStorageFor($category) 인스턴스가 주입되어, 확장이
* getStorageDiskFor() 를 오버라이드하면 해당 서비스의 put/getDisk() 가
* 서비스 코드 무수정으로 카테고리 디스크를 따릅니다.
*
* @var array<class-string, string>
*/
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));
}
}
/**
+69
View File
@@ -1309,6 +1309,75 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return 'modules';
}
/**
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
*
* @var array<string, StorageInterface>
*/
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;
}
/**
* 모듈 캐시 드라이버 인스턴스 반환
*
+69
View File
@@ -1203,6 +1203,75 @@ abstract class AbstractPlugin implements CacheableExtensionInterface, PluginInte
return 'plugins';
}
/**
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
*
* @var array<string, StorageInterface>
*/
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;
}
/**
* 플러그인 캐시 드라이버 인스턴스 반환
*
@@ -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();
}
}
@@ -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();
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Extension\Storage\Concerns;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Storage;
/**
* 스토리지 드라이버 공용 공개 URL 해석 trait
*
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
* 직접 URL 을 생성하고, 그 외에는 null 을 생성합니다. 생성 결과는 디스크 종류와 무관하게
* `core.storage.filter_url` 필터 훅을 항상 통과하므로, 확장이 URL 을 공급/수정/차단할 수 있습니다.
*/
trait ResolvesPublicUrl
{
/**
* 파일의 공개 URL을 반환합니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로 (카테고리 하위 상대 경로)
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
*/
public function url(string $category, string $path): ?string
{
$fullPath = $this->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;
}
+14 -11
View File
@@ -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);
+14 -12
View File
@@ -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);
+14 -12
View File
@@ -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);
@@ -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<string, mixed> $settings 응답에 실을 설정
* @return array<string, mixed> 카탈로그가 부착된 설정
*/
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) ?? [])
)
);
}
@@ -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회 경고가 남는다.
@@ -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'),
];
}
}
+16 -1
View File
@@ -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;
}
/**
+35
View File
@@ -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 연결 설정을 적용합니다.
*/
+4
View File
@@ -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'],
@@ -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)',
+13
View File
@@ -125,6 +125,19 @@ return [
'like_operator_default' => 'like',
],
/*
|--------------------------------------------------------------------------
| 스토리지 — 공개 자산 직접 URL 서빙 디스크
|--------------------------------------------------------------------------
| 완전 공개 자산(상품/카테고리/리뷰/에디터 이미지)의 직접 URL(CDN) 서빙에 쓸
| 디스크입니다. 빈 문자열이면 미설정(기존 PHP 스트리밍 유지)이며, 값은 관리자
| 환경설정(drivers.public_asset_disk)에서 SettingsServiceProvider 가 주입합니다.
| 확장은 개별 설정으로 이 전역값을 오버라이드할 수 있습니다.
*/
'storage' => [
'public_asset_disk' => '',
],
/*
|--------------------------------------------------------------------------
| 코어 권한 정의
+2
View File
@@ -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 },
+1
View File
@@ -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.*` |
+81 -15
View File
@@ -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
<!-- @generated:start:api.admin.plugins.settings.update -->
@@ -1641,7 +1643,7 @@ _단건 응답: `data` 는 저장 후 다시 조회한 설정 값 맵입니다.
<!-- @generated:end -->
**설명** 플러그인의 설정 값을 저장합니다. `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` 내부)
<!-- 실측 제외: http-422 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 JSON 봉투를 반환하지 않습니다 — `?file=` 쿼리로 받은 경로의 에셋
파일 원본 바이트를 그대로 서빙합니다 (성공 시 200 또는 304). 파일 경로 미전달
(`file` 쿼리 부재)은 422 로 응답합니다._
| 응답 헤더 | 예시값 | 용도/설명 |
| --- | --- | --- |
| Content-Type | `text/javascript` | 파일 확장자에서 감지한 MIME 타입 |
| ETag | `"a1b2c3d4…"` | 파일 내용 기반 검증자 (재요청 시 304 판정) |
| Cache-Control | `public, max-age=31536000` | 1년 캐시 (환경별로 달라짐 — 비프로덕션은 no-cache) |
**응답 예시**
<!-- 실측 제외: http-422 — 응답 예시는 사람이 작성하세요. -->
```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
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 플러그인 에셋 서빙(`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` 내부)
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 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-200 — 응답 예시는 사람이 작성하세요. -->
```http
HTTP/1.1 200
Content-Type: text/css
Cache-Control: public, max-age=31536000
/* sirsoft-gdpr */
.g7-gdpr-banner{position:fixed;bottom:0}
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 플러그인 CSS 번들(`GET /api/plugins/bundle.css`)의 확장자 없는 이중 모드 변형입니다. 동작·응답이 확장자 형태와 동일하며, `.css` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/plugins/bundle/js
@@ -2460,19 +2498,37 @@ Accept: application/json
**응답 필드** (`data` 내부)
<!-- 실측 제외: http-200 — 응답 필드는 사람이 작성하세요. -->
_이 엔드포인트는 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-200 — 응답 예시는 사람이 작성하세요. -->
```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 */})();
```
**에러 응답**
_대표 에러 없음 (공개 조회). <!-- TODO: 도메인 특이 에러가 있으면 보강 -->_
_에러 응답 없음 — 공개 조회이며 요청 파라미터가 없고, 활성 에셋이 하나도 없는 경우에도 빈 200 을 반환합니다._
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 플러그인 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
<!-- @generated:end -->
**설명** <!-- TODO: 이 엔드포인트의 용도·주의사항·예시 시나리오를 작성하세요 -->
**설명** 플러그인 컴포넌트 매니페스트(`GET /api/plugins/{identifier}/components.json`)의 확장자 없는 이중 모드 변형입니다. 응답·캐시·폴백 동작이 확장자 형태와 동일하며, `.json` 주소를 가로채는 정적 파일 최적화 서버 설정에서 프론트가 이 형태로 자동 전환합니다 (자산 URL 이중 모드).
### GET /api/plugins/{identifier}/components.json
+2 -1
View File
@@ -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 액세스 키 |
+35
View File
@@ -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 이 생성됩니다(없으면 스트리밍 폴백). 플러그인이 비활성화되어 디스크 정의가 사라지면 저장값은 보존된 채 스트리밍으로 자동 폴백합니다.
---
## 리스너 구현
+106 -26
View File
@@ -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
<?php
@@ -1346,39 +1372,37 @@ if (Storage::disk('local')->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)
@@ -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
@@ -36,6 +36,11 @@ return [
'local' => 'ローカル',
's3' => 'Amazon S3',
],
'public_asset' => [
'none' => '使用しない (ストリーミング)',
'public' => 'Public ディスク',
's3' => 'Amazon S3',
],
'cache' => [
'file' => 'ファイル',
'redis' => 'Redis',
@@ -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' => [
@@ -11,6 +11,8 @@
- 배송정책 구간 설정의 새 검증 안내(구간 미등록, 중간 구간 종료값 누락, 수량 구간의 정수 제한, 단위값·무료배송 기준금액 누락, 도서산간 우편번호 형식) 일본어 번역을 추가했습니다.
- 배송정책 편집 화면에서 구간 입력 오류를 안내하는 문구(중간 구간 종료값 누락, 배송비 미입력, 수량 구간 정수 제한, 단위값·무료배송 기준금액 미입력) 일본어 번역을 추가했습니다.
- 배송비 요약에 표시되는 구간 단위(개/kg/L)와 구간 입력란의 단위 라벨 일본어 번역을 추가했습니다.
- 이커머스 설정 기본 정보 탭의 "공개 자산 스토리지" 설정 문구 일본어 번역을 추가했습니다.
- 공개 자산 디스크에 사용할 수 없는 값을 저장할 때 나오는 오류 안내와 필드명의 일본어 번역을 추가했습니다.
### Changed
@@ -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' => 'ルートパス',
@@ -58,6 +58,13 @@
"telecom_number_placeholder": "付加通信事業者番号",
"postcode_plugin_required": "Daum郵便番号プラグインがインストールされていないか無効化されています。管理者>プラグインで有効化してください。"
},
"public_asset_storage": {
"title": "公開アセットストレージ",
"description": "商品・カテゴリ・レビュー画像など、このモジュールの完全公開アセットの保存ディスクを設定します。",
"disk": "公開アセットディスク",
"follow_core": "コア設定に従う",
"help": "このモジュールだけ別のディスクを使う場合に選択します。空にするとコア環境設定の公開アセットストレージ設定に従います。"
},
"inquiry_settings": {
"title": "お問い合わせ掲示板設定",
"description": "商品お問い合わせ機能を設定します。",
@@ -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
@@ -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)"
@@ -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",
@@ -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
@@ -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": "アプリケーションキャッシュドライバーを設定します。",
+5
View File
@@ -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',
+4
View File
@@ -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',
],
];
+5
View File
@@ -43,6 +43,11 @@ return [
'local' => '로컬',
's3' => 'Amazon S3',
],
'public_asset' => [
'none' => '사용 안 함 (스트리밍)',
'public' => 'Public 디스크',
's3' => 'Amazon S3',
],
'cache' => [
'file' => '파일',
'redis' => 'Redis',
+4
View File
@@ -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' => '공개 자산 디스크',
],
];
@@ -9,6 +9,8 @@
### Changed
- 글 저장·수정 시 비밀글 여부 값을 문자열(`"true"`/`"false"`)로 보내는 클라이언트도 수용하도록 해석을 관대화했습니다. 해석할 수 없는 값은 종전과 동일하게 거부됩니다.
- 첨부 미리보기·작성자 아바타 주소 조립을 코어와 공유하는 단일 지점으로 정리했습니다. 표시되는 주소와 동작은 종전과 동일합니다.
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
### Fixed
@@ -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 여부 |
+1 -1
View File
@@ -11,7 +11,7 @@
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
"g7_version": ">=7.0.6",
"g7_version": ">=7.0.7",
"dependencies": {
"modules": {},
"plugins": {}
@@ -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;
@@ -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';
}
/**
@@ -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')
@@ -0,0 +1,46 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 게시판 첨부 미리보기 URL 조립 단일 지점(previewUrlForSlug) 회귀 테스트
*
* URL 중복 조립 정리(#100) 후에도 응답 문자열이 기존과 동일함을 고정합니다.
* board 는 직접 URL 배선 대상이 아니므로 문자열 회귀 0 이 계약입니다.
*/
class AttachmentPreviewUrlTest extends ModuleTestCase
{
/**
* @effects board_response_strings_unchanged
*/
#[Test]
public function preview_url_for_slug_builds_board_preview_url(): void
{
$attachment = new Attachment;
$attachment->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
);
}
}
@@ -0,0 +1,77 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Repositories;
use App\Models\Attachment as CoreAttachment;
use App\Models\User;
use Modules\Sirsoft\Board\Repositories\BoardRepository;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 인기 게시물 아바타 URL 호출측 회귀 테스트 (#100 URL 조립 단일화)
*
* urlForHash() 헬퍼 자체가 아니라 **호출측**(BoardRepository getPopularPosts)이
* 조인으로 얻은 hash 를 코어 단일 지점에 위임해 응답 문자열이 기존과 동일하게
* 직렬화되는지 고정합니다.
*/
class BoardRepositoryPopularAvatarTest extends BoardTestCase
{
/**
* @effects board_response_strings_unchanged
*/
#[Test]
public function popular_posts_avatar_serializes_core_attachment_url(): void
{
$user = User::factory()->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']);
}
}
@@ -0,0 +1,78 @@
<?php
namespace Modules\Sirsoft\Board\Tests\Unit\Resources;
use Illuminate\Http\Request;
use Modules\Sirsoft\Board\Http\Resources\PostResource;
use Modules\Sirsoft\Board\Models\Attachment;
use Modules\Sirsoft\Board\Models\Post;
use Modules\Sirsoft\Board\Tests\BoardTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* PostResource 썸네일 URL 호출측 회귀 테스트 (#100 URL 조립 단일화)
*
* previewUrlForSlug() 헬퍼 자체가 아니라 **호출측**(PostResource
* getThumbnailUrlFromRelations)이 slug 인자를 올바르게 전달해 응답 문자열이
* 기존과 동일하게 직렬화되는지 고정합니다 — 헬퍼 출력만 고정하면 인자 전달
* 오류(슬러그 누락 등)가 전부 green 으로 통과합니다.
*/
class PostResourceThumbnailUrlTest extends BoardTestCase
{
/**
* 이미지 첨부를 가진 게시글을 생성합니다.
*
* @param string $mimeType 첨부 MIME 타입
* @return array{post: Post, attachment: Attachment} 게시글/첨부
*/
private function createPostWithAttachment(string $mimeType): array
{
$postId = $this->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']);
}
}
@@ -9,6 +9,11 @@
### Added
- 리뷰 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 상품·카테고리 이미지와 동일하게 리뷰 이미지도 업로드 시점에 변환(압축·리사이즈·포맷 변경)할 수 있습니다. (#96 @lyg-kaban 님께서 건의해주셨습니다.)
- 상품·카테고리·리뷰 이미지를 코어의 공개 자산 스토리지(S3+CDN 등) 디스크에 저장하고 화면에는 CDN 직접 주소로 서빙할 수 있습니다. 기본정보 탭의 「공개 자산 스토리지」에서 이 모듈만 다른 디스크를 쓰도록 개별 설정할 수도 있습니다(비우면 코어 설정 따름). 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시·삭제됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
### Changed
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
### Fixed
@@ -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, // 구매확정 후 리뷰 작성 가능 기간 (일)
@@ -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": {
@@ -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 옵션 소스 |
**응답 예시**
@@ -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": {}
@@ -1056,6 +1056,43 @@ class Module extends AbstractModule
];
}
/**
* 완전 공개 자산 스토리지 카테고리 목록
*
* 이 카테고리들만 공개 자산 디스크 설정을 따른다. 권한 검사가 걸린 자산
* (회원 전용/비밀 자료 등)은 직접 URL 이 권한을 우회하므로 포함하지 않는다.
* 새 공개 자산 카테고리가 생기면 여기에만 추가하면 된다.
*
* @var list<string>
*/
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();
}
/**
* 모듈 설치 시 실행할 시더 목록 반환
*
@@ -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 }) => (
<div className={className} data-testid={testId}>{children}</div>
);
const TestH3: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <h3 className={className}>{children || text}</h3>;
const TestLabel: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <label className={className}>{children || text}</label>;
const TestSpan: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <span className={className}>{children || text}</span>;
const TestSelect: React.FC<{
name?: string; value?: string; className?: string; options?: any[]; disabled?: boolean;
error?: string; onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}> = ({ name, value, className, options, disabled, onChange }) => (
<select data-name={name} value={value} className={className} disabled={disabled} onChange={onChange}>
{options?.map((o) => <option key={String(o.value)} value={o.value}>{o.label}</option>)}
</select>
);
// 렌더러가 컴포넌트 목록을 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<typeof createLayoutTest> | 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',
]);
});
});
@@ -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.",
@@ -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 문의 기능을 설정합니다.",
@@ -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",
@@ -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',
@@ -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'));
}
};
}
/**
* 검증 속성명 다국어 처리
*
@@ -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)
);
}
}
@@ -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;
}
@@ -0,0 +1,65 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Models\Concerns;
use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
/**
* 공개 자산 모델 공용 download_url 해석 trait
*
* 행에 기록된 disk 로 직접 URL(CDN) 생성을 시도하고, 불가하면(로컬 디스크,
* url 미설정 디스크, 훅 차단 등) 기존 API 서빙 경로로 폴백합니다.
* 디스크 전환 이전에 업로드된 로컬 행과 이후의 원격 행이 혼재해도
* 각 행이 자기 disk 기준으로 올바른 URL 을 얻습니다.
*
* 스토리지 카테고리는 directUrlCategory() 오버라이드로 바꿀 수 있어(기본 'images')
* 이미지 외의 완전 공개 자산 모델도 재사용할 수 있습니다. 단, 권한 검사가 걸린
* 첨부파일(비밀글/회원 전용 게시판 등)에는 배선하지 않습니다 — 직접 URL 은
* 서버 스트리밍 경로의 권한 검사를 우회합니다.
*/
trait HasDirectAssetUrl
{
/**
* `download_url` 가상 attribute — 직접 URL 우선, 불가 시 API 서빙 URL.
*
* @return string 자산 URL
*/
public function getDownloadUrlAttribute(): string
{
return $this->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;
}
@@ -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;
}
@@ -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;
}
@@ -119,10 +119,21 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider
* @var array<int, class-string>
*/
protected array $storageServices = [
CategoryImageService::class,
ProductImageService::class,
ProductReviewService::class,
ProductReviewImageService::class,
];
/**
* 카테고리별 StorageInterface가 필요한 서비스 매핑 (클래스 ⇒ 카테고리)
*
* 이미지 서비스는 getStorageDiskFor('images') 가 결정한 디스크(공개 자산 디스크
* 설정 반영)를 주입받아, put/getDisk() 행 기록이 자동으로 카테고리 디스크를 따릅니다.
*
* @var array<class-string, string>
*/
protected array $storageCategoryServices = [
CategoryImageService::class => 'images',
ProductImageService::class => 'images',
ProductReviewImageService::class => 'images',
];
/**
@@ -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에서 삭제
@@ -0,0 +1,39 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Services\Concerns;
use App\Contracts\Extension\StorageInterface;
/**
* 이미지 행 disk 기준 스토리지 해석 trait (서비스 공용)
*
* 디스크 전환 이후에도 전환 이전 행(구 disk)의 서빙/삭제/이동이 그 행의
* 실제 저장 위치를 향하도록, 행에 기록된 disk 의 스토리지를 돌려줍니다.
* 사용 서비스는 `protected StorageInterface $storage` 주입을 전제로 합니다.
*/
trait ResolvesRowStorage
{
/**
* 이미지 행에 기록된 disk 기준 스토리지를 반환합니다.
*
* @param string|null $disk 이미지 행의 disk 컬럼 값 (비어 있으면 주입 스토리지)
* @return StorageInterface 행 disk 의 스토리지
*/
protected function storageForRow(?string $disk): StorageInterface
{
if ($disk === null || $disk === '' || $disk === $this->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);
}
}
@@ -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);
}
/**
@@ -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,
@@ -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);
}
}
}
@@ -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),
@@ -0,0 +1,43 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Support;
use App\Contracts\Extension\StorageInterface;
use App\Extension\Storage\ModuleStorageDriver;
/**
* 자산 행 disk 기준 스토리지 드라이버 팩토리
*
* 자산 행마다 기록된 disk 가 다를 수 있어(디스크 전환 후 혼재 운용),
* 행 disk 별 ModuleStorageDriver 인스턴스를 memoize 하여 제공합니다.
* 모델 accessor(HasDirectAssetUrl)가 직렬화 루프에서 반복 호출하므로
* 드라이버 생성 비용을 요청 단위 1회로 고정합니다.
*/
class AssetStorage
{
/**
* 디스크명 키 드라이버 캐시
*
* @var array<string, StorageInterface>
*/
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 = [];
}
}
@@ -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',
@@ -1327,8 +1327,14 @@ return [
],
],
// 공개 자산 디스크 (코어 카탈로그 조회 검증)
'public_asset_disk_invalid' => '올바른 공개 자산 디스크를 선택해주세요.',
// 필드명 한국어 변환 (Laravel 표준)
'attributes' => [
// 공개 자산 스토리지
'basic_info.public_asset_disk' => '공개 자산 디스크',
// 배송정책 국가별 설정
'country_settings' => '국가별 설정',
'country_settings.*.country_code' => '국가',
@@ -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<void> {
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<string[]> {
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<unknown> {
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<void> {
await openAndReadOptions(page);
await page.locator('[role="listbox"] [role="option"]', { hasText: label }).first().click();
}
/** 저장 버튼을 눌러 성공 토스트를 기다린다. */
async function save(page: Page): Promise<void> {
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 || '');
});
});
@@ -0,0 +1,113 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Http\Requests;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Config;
use Illuminate\Validation\ValidationException;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreEcommerceSettingsRequest;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 이커머스 공개 자산 디스크 저장 검증 테스트 (공개#100)
*
* 모듈 오버라이드 필드도 코어 환경설정(SaveSettingsRequest)과 같은 강도로 검증한다.
* 이 게이트가 없으면 오타 디스크명이 200 으로 저장된 뒤 런타임 폴백만 남아,
* 운영자에게는 "저장은 됐는데 CDN 이 안 붙는" 무증상 상태로만 보인다.
*
* 선택지는 코어 3종 + 플러그인 훅 등록 디스크라 정적 in 으로 고정할 수 없으므로,
* 카탈로그 게터(SSoT)를 조회하는 closure 가 실제로 걸려 있는지를 고정한다.
*
* @effects invalid_disk_rejected_with_422, settings_catalog_includes_plugin_registered_disks
*/
class StoreEcommerceSettingsRequestPublicAssetDiskTest extends ModuleTestCase
{
protected function tearDown(): void
{
HookManager::resetAll();
parent::tearDown();
}
/**
* basic_info 탭 저장 요청을 해석합니다.
*
* @param string $disk public_asset_disk 값
*/
private function resolve(string $disk): StoreEcommerceSettingsRequest
{
$request = StoreEcommerceSettingsRequest::create('/', 'POST', [
'_tab' => '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']);
}
}
@@ -0,0 +1,152 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Models;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Models\CategoryImage;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Models\ProductReviewImage;
use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* HasDirectAssetUrl(download_url 직접 URL/폴백) 단위 테스트
*
* 행 disk 가 직접 URL 지원 디스크(fake_cdn)면 CDN URL, 아니면 기존 API 경로 폴백을
* 3개 이미지 모델(상품/카테고리/리뷰)에서 검증합니다. 혼재 운용(구 로컬 행 + 신규
* 원격 행)과 훅 차단 폴백도 함께 고정합니다.
*/
class HasDirectAssetUrlTest extends ModuleTestCase
{
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',
]);
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
);
}
}
@@ -0,0 +1,189 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\Storage\ModuleStorageDriver;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Modules\Sirsoft\Ecommerce\Models\CategoryImage;
use Modules\Sirsoft\Ecommerce\Services\CategoryImageService;
use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* CategoryImageService 행 disk 기준 서빙/삭제 테스트
*
* 공개 자산 디스크 전환 이후에도 전환 이전 행(구 disk)의 서빙이 404 가 되지 않고,
* 삭제가 그 행의 실제 저장 위치를 향하는지(혼재 운용 정합) 검증합니다.
*
* CategoryImageServiceTest 는 StorageInterface 를 Mock 으로 대체하므로
* 실제 디스크 해석(withDisk) 회귀를 검출할 수 없어 별도 파일로 둡니다.
*/
class CategoryImageServiceRowDiskTest extends ModuleTestCase
{
private CategoryImageService $service;
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',
]);
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));
}
}
@@ -0,0 +1,285 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\Storage\ModuleStorageDriver;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Services\ProductImageService;
use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* ProductImageService 행 disk 기준 서빙/삭제 테스트
*
* 디스크 전환 이후에도 전환 이전 행(구 disk)의 서빙이 404 가 되지 않고,
* 삭제가 그 행의 실제 저장 위치를 향하는지(혼재 운용 정합) 검증합니다.
*/
class ProductImageServiceRowDiskTest extends ModuleTestCase
{
private ProductImageService $service;
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',
]);
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));
}
}
@@ -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()
@@ -0,0 +1,209 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\Storage\ModuleStorageDriver;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Modules\Sirsoft\Ecommerce\Enums\ReviewStatus;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductReview;
use Modules\Sirsoft\Ecommerce\Models\ProductReviewImage;
use Modules\Sirsoft\Ecommerce\Services\ProductReviewImageService;
use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* ProductReviewImageService 행 disk 기준 서빙/삭제 테스트
*
* 공개 자산 디스크 전환 이후에도 전환 이전 행(구 disk)의 서빙이 404 가 되지 않고,
* 삭제가 그 행의 실제 저장 위치를 향하는지(혼재 운용 정합) 검증합니다.
*
* ProductReviewImageServiceTest 는 StorageInterface 를 Mock 으로 대체하므로
* 실제 디스크 해석(withDisk) 회귀를 검출할 수 없어 별도 파일로 둡니다.
*/
class ProductReviewImageServiceRowDiskTest extends ModuleTestCase
{
private ProductReviewImageService $service;
private ProductReview $review;
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',
]);
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));
}
}
@@ -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()
@@ -0,0 +1,97 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Str;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductImage;
use Modules\Sirsoft\Ecommerce\Services\ProductService;
use Modules\Sirsoft\Ecommerce\Support\AssetStorage;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* 상품 상세 폼 직렬화 이미지 URL 호출측 회귀 테스트 (#100 URL 조립 단일화)
*
* accessor(download_url) 자체가 아니라 **호출측**(ProductService getDetailForForm)이
* accessor 에 위임해 직렬화하는지 고정합니다 — 손조립 문자열로 회귀하면 행 disk
* 기준 직접 URL(혼재 운용)이 화면에서 사라집니다.
*/
class ProductServiceDetailImageUrlTest extends ModuleTestCase
{
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',
]);
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'],
);
}
}
@@ -0,0 +1,126 @@
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit;
use App\Extension\ModuleManager;
use App\Services\ModuleSettingsService;
use Illuminate\Support\Facades\Config;
use Modules\Sirsoft\Ecommerce\Module;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\Test;
/**
* getStorageDiskFor 설정 로드 순환 참조 회귀 테스트 (실제 설정 로드 경로)
*
* 실제 모듈 인스턴스의 getStorageDiskFor 가 module_setting() 을 경유할 때,
* 설정 로드 자신이 스토리지('settings' 카테고리)를 다시 요구하는 상황을
* 컨테이너 스파이로 재현합니다. PUBLIC_ASSET_CATEGORIES 게이트가 제거되면
* 'settings' 해석이 설정 서비스를 재호출해 호출 횟수 단언이 깨집니다.
*/
class StorageDiskRecursionTest extends ModuleTestCase
{
private Module $module;
/**
* 설정 로드가 스토리지를 경유하는 경로를 재현하는 스파이
*/
private object $settingsSpy;
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', '');
$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);
}
}
@@ -9,6 +9,11 @@
### Added
- 에디터 이미지 업로드에 파일 가공 필터 훅 제공 — 확장에서 업로드 이미지를 저장 전에 변환할 수 있습니다.
- 에디터에 올린 이미지를 코어의 공개 자산 스토리지(S3+CDN 등) 디스크에 저장하고 본문에는 CDN 직접 주소로 서빙할 수 있습니다. 에디터 설정의 「공개 자산 디스크」에서 이 플러그인만 다른 디스크를 쓰도록 개별 설정할 수도 있습니다(비우면 코어 설정 따름). 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
### Changed
- 코어 최소 요구 버전을 7.0.7 로 상향했습니다.
## [1.0.1] - 2026-08-10
@@ -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 }
}
}
@@ -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 가 이 값을 그대로 `<img src>` 에 삽입한다. |
| 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 가 이 값을 그대로 `<img src>` 에 삽입한다. |
실패 시에는 최상위 `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 아님)
@@ -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": {}
@@ -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<string>
*/
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();
}
/**
* 플러그인이 관리하는 동적 테이블 목록 반환
*
@@ -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 }) => (
<div className={className} data-testid={testId}>{children}</div>
);
const TestLabel: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <label className={className}>{children || text}</label>;
const TestP: React.FC<{ className?: string; children?: React.ReactNode; text?: string }> =
({ className, children, text }) => <p className={className}>{children || text}</p>;
const TestSelect: React.FC<{
name?: string; value?: string; className?: string; options?: any[];
onChange?: (e: React.ChangeEvent<HTMLSelectElement>) => void;
}> = ({ name, value, className, options, onChange }) => (
<select data-name={name} value={value} className={className} onChange={onChange}>
{options?.map((o) => <option key={String(o.value)} value={o.value}>{o.label}</option>)}
</select>
);
// 렌더러가 컴포넌트 목록을 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<typeof createLayoutTest> | 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',
]);
});
});
@@ -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)"
@@ -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)"
@@ -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)})))}}"
}
}
]
}
]
}
]
}
@@ -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<string, StorageInterface>
*/
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 = [];
}
/**
@@ -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<class-string, string>
*/
protected array $storageCategoryServices = [
ImageUploadService::class => 'images',
];
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more