Merge pull request from gnuboard:HeuJung/issue562
feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
This commit is contained in:
commit
a5a7caf8ba
125 files changed
+5913
-203
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
|
||||
*
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 캐시 드라이버 인스턴스 반환
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 연결 설정을 적용합니다.
|
||||
*/
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -125,6 +125,19 @@ return [
|
||||
'like_operator_default' => 'like',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 스토리지 — 공개 자산 직접 URL 서빙 디스크
|
||||
|--------------------------------------------------------------------------
|
||||
| 완전 공개 자산(상품/카테고리/리뷰/에디터 이미지)의 직접 URL(CDN) 서빙에 쓸
|
||||
| 디스크입니다. 빈 문자열이면 미설정(기존 PHP 스트리밍 유지)이며, 값은 관리자
|
||||
| 환경설정(drivers.public_asset_disk)에서 SettingsServiceProvider 가 주입합니다.
|
||||
| 확장은 개별 설정으로 이 전역값을 오버라이드할 수 있습니다.
|
||||
*/
|
||||
'storage' => [
|
||||
'public_asset_disk' => '',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 코어 권한 정의
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 액세스 키 |
|
||||
|
||||
@@ -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 이 생성됩니다(없으면 스트리밍 폴백). 플러그인이 비활성화되어 디스크 정의가 사라지면 저장값은 보존된 채 스트리밍으로 자동 폴백합니다.
|
||||
|
||||
---
|
||||
|
||||
## 리스너 구현
|
||||
|
||||
@@ -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' => 'ルートパス',
|
||||
|
||||
+7
@@ -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": "アプリケーションキャッシュドライバーを設定します。",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -43,6 +43,11 @@ return [
|
||||
'local' => '로컬',
|
||||
's3' => 'Amazon S3',
|
||||
],
|
||||
'public_asset' => [
|
||||
'none' => '사용 안 함 (스트리밍)',
|
||||
'public' => 'Public 디스크',
|
||||
's3' => 'Amazon S3',
|
||||
],
|
||||
'cache' => [
|
||||
'file' => '파일',
|
||||
'redis' => 'Redis',
|
||||
|
||||
@@ -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 여부 |
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+77
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 설치 시 실행할 시더 목록 반환
|
||||
*
|
||||
|
||||
+249
@@ -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 문의 기능을 설정합니다.",
|
||||
|
||||
+83
@@ -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
@@ -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',
|
||||
|
||||
+30
-1
@@ -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'));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 검증 속성명 다국어 처리
|
||||
*
|
||||
|
||||
+2
-1
@@ -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' => '국가',
|
||||
|
||||
+136
@@ -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 || '');
|
||||
});
|
||||
});
|
||||
+113
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
+189
@@ -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));
|
||||
}
|
||||
}
|
||||
+285
@@ -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()
|
||||
|
||||
+209
@@ -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));
|
||||
}
|
||||
}
|
||||
+15
@@ -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()
|
||||
|
||||
+97
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인이 관리하는 동적 테이블 목록 반환
|
||||
*
|
||||
|
||||
+248
@@ -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
Reference in new issue
Block a user