# 뷰티샵 플랫폼 중앙 집중형 이미지 라이브러리 및 멀티텐트 관리 아키텍처 명세서

## 1. 개요 및 도입 배경
가상서버호스팅(VPS) 환경에서 디스크 용량 한계와 네트워크 트래픽을 효율적으로 관리하고, 다중 뷰티샵(Multi-tenant SaaS) 구조에서의 데이터 프라이버시 및 미용업법 규제(과대광고, 미용 양속 저해 방지)를 준수하기 위해 중앙 집중형 이미지 라이브러리 시스템을 구축합니다.

기존의 파편화된 업로드 방식을 탈피하여 단일 진입점인 `ImageService`와 영역별 `ImageController`를 통해 SHA-256 해시 기반 중복 차단, 뷰티샵 소유권(`shop_id`) 및 공개 범위(`visibility`) 격리, 그리고 최고관리자의 프로그램 승인 워크플로우(`status`)를 통합 제공합니다.

---

## 2. 인터페이스 전략 및 역할 분담

### 2.1 공용 미디어 라이브러리 모달 (Modal) 중심 UX
- **페이지 이동 없는 즉시성**: 뷰티샵 기본정보 수정, 시술 프로그램 등록, 제품 관리 페이지 등 어디서든 페이지 새로고침 없이 팝업창을 통해 이미지 선택 및 대량 업로드를 수행합니다.
- **TinyMCE 에디터 연동**: 에디터 작성 중 `file_picker_callback`을 통해 미디어 모달을 직접 호출하여 본문 내 이미지를 즉시 삽입하고 동기화합니다.
- **컴포넌트 재사용성**: 관리자 레이아웃 최하단에 단 하나의 미디어 모달 컴포넌트(`media-modal.blade.php`)를 배치하여 전역에서 공유합니다.

### 2.2 모달과 관리 페이지의 역할 분담
- **공용 미디어 모달 (Popup)**: 보관함 탐색/검색, 다건 파일 대량 업로드(진행률 표시), 폼 인풋 및 에디터로 이미지 ID 전달
- **관리자 전용 관리 페이지 (Page)**: 전체 이미지 리소스 모니터링, 고아 이미지(Orphan Files) 선별 및 일괄 삭제, 폴더 구조(`image_folders`) 관리

---

## 3. 권한 및 영역별 컨트롤러 아키텍처

기존 프로젝트 구조와 일관성을 유지하고 보안을 강화하기 위해 `ImageController`를 관리자 영역별로 분리하며, 핵심 비즈니스 로직은 공용 `ImageService`로 통합합니다.

- **디렉터리 구조**
    app/
    ├── Services/
    │   └── ImageService.php                  # [공용] 파일 업로드, 해시 검증, 고아 파일 정리 통합 서비스
    └── Http/
        └── Controllers/
            ├── Admin/
            │   └── ImageController.php         # [최고관리자용] 전체 미디어 보관함 및 전체 고아 파일 정리 통제
            └── ShopAdmin/
                └── ImageController.php         # [뷰티샵관리자용] 매장 전용 모달 연동 및 업로드/조회 제어

- **영역별 권한 정책**
    - **최고관리자 (`Admin\ImageController`)**: 플랫폼 전체의 모든 이미지 및 고아 파일 통제 권한 보유
    - **뷰티샵 관리자 (`ShopAdmin\ImageController`)**: 현재 세션의 샵 ID(`active_shop_id`) 및 본인 업로드 건(`uploaded_by`)으로 권한 범위 제한

---

## 4. 멀티텐트 프라이버시 및 승인 워크플로우 정책

### 4.1 이미지 공개 범위 및 소유권 격리 (`visibility` & `shop_id`)
- **소유권 (`shop_id`)**: 업로드한 뷰티샵의 식별 번호 (최고관리자 업로드는 `null`로 공용 소유)
- **공개 범위 (`visibility`)**:
    - `public`: 플랫폼 공용 또는 타 매장/최고관리자 조회 및 재사용 허용
    - `private`: 업로드한 뷰티샵 전용 (타 매장 및 최고관리자 보관함에서 조회 및 사용 원천 차단)

### 4.2 최고관리자 승인 워크플로우 (`status`)
- 뷰티샵이 등록한 시술 프로그램은 기본적으로 `pending` 상태로 생성되어 소비자에게 노출되지 않습니다.
- 최고관리자가 검토 후 `approved`로 승인해야 비로소 프론트엔드에 정상 노출되며, 부적절한 경우 `rejected`(반려) 처리됩니다.
- 미용 양속을 저해하거나 과도한 광고성 이미지가 발견될 경우 최고관리자에 의해 제재 및 연계 차단(Cascade Restriction)이 수행됩니다.

---

## 5. 이미지 저장 방식 및 라이프사이클 통합 규정

### 5.1 스토리지 물리 폴더 및 네이밍 규칙
- **최고관리자 공용 영역**: `storage/app/public/admin/{domain}/` (예: `admin/programs/`)
- **뷰티샵 및 프로그램 소유 영역**: 
    - 뷰티샵 관련 이미지: `storage/app/public/shops/{shop_id}/`
    - 프로그램 관련 이미지: `storage/app/public/programs/{program_id}/`
- **파일명 접두어(Prefix) 규칙**: 가독성과 용도 구분을 위해 파일명에 접두어 조합 (예: `logo_`, `banner_`, `og_`, `marketing_`, `content_` + SHA-256 해시 일부 + 확장자)

### 5.2 임시 폴더 및 해시 기반 중복 방지 (Temp Stage)
- **해시 기반 공용 임시 공간**: 토큰 단위로 임시 폴더를 쪼개어 발생할 수 있는 꼬임 현상을 방지하기 위해, 동일 파일 업로드 시 SHA-256 해시를 기준으로 `temp/{hash}.webp` 형태로 서버에 단 1장만 임시 적재됩니다.
- **방치형 구조**: 폼 작성 중 이탈하더라도 시스템이 임의로 파일을 삭제하지 않으며, 다른 사용자가 동일한 이미지를 요청할 경우 기존 임시 파일을 안전하게 참조(공유)합니다.

### 5.3 정식 폴더 승격 및 M:N 공유 (Promotion & Lifecycle Stage)
- **영구 이동(Move)**: 뷰티샵 또는 프로그램 등록/수정 시 [저장] 버튼을 누르면 ID가 발급됨과 동시에 물리 파일이 정식 ID 폴더(`shops/{shop_id}/` 또는 `programs/{program_id}/`)로 영구 이동하며 위치가 고정됩니다.
- **제로 물리 중복 (Zero Physical Duplication)**: 한 번 정식 폴더에 적재된 파일은 삭제될 때까지 그 위치에 고정되며, 다른 엔티티에서 재사용할 경우 물리 복사 없이 `imageables` 다형성 피벗 테이블을 통한 M:N 연결(Sharing)로만 분배됩니다.

### 5.4 수동 고아 파일 정산 (Manual Cleanup)
- 자동 크론 스케줄러를 배제하고, 관리자가 전용 관리 페이지에서 어떠한 엔티티와도 연결되지 않은 고아 이미지들을 직접 선별하여 일괄 삭제(Purge)할 수 있습니다.
- 파일 삭제 시 더 이상 잔여 파일이 없는 빈 디렉터리는 자동으로 소멸하여 디스크 용량을 최적화합니다.

---

## 6. 데이터베이스 테이블 명세서 및 마이그레이션 완성 코드

### 6.1 `image_folders` (이미지 폴더 분류 테이블)
* **테이블 설명**: 미디어 라이브러리 내 계층형 디렉터리 구조 관리 및 소유자별 폴더 격리
* **관련 마이그레이션**: `database/migrations/2026_01_01_000001_create_image_folders_table.php`

| 번호 | PK/FK | 컬럼명 | 타입 | Null | 기본값 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- |
| 1 | PK | id | Bigint (unsigned) | N | Auto | 폴더 고유 식별 번호 |
| 2 | - | name | String(100) | N | - | 폴더명 (예: 매장 전경, 시술 전후, 제품 프로필) |
| 3 | FK | parent_id | Bigint (unsigned) | Y | null | 상위 폴더 식별 번호 (계층형 디렉터리, `image_folders.id` 참조, Cascade) |
| 4 | FK | created_by | Bigint (unsigned) | Y | null | 폴더 생성자 유저 식별 번호 (`users.id` 참조, NullOnDelete) |
| 5 | - | sort_order | Integer | N | 0 | 폴더 노출 정렬 순서 (오름차순) |
| 6 | - | created_at | Timestamp | Y | null | 생성 일시 |
| 7 | - | updated_at | Timestamp | Y | null | 수정 일시 |

#### `image_folders` 마이그레이션 코드

    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;

    return new class extends Migration
    {
        public function up(): void
        {
            Schema::create('image_folders', function (Blueprint $table) {
                $table->id();$table->string('name', 100)->comment('폴더명 (예: 매장 전경, 시술 전후, 제품 등)');
                $table->foreignId('parent_id')->nullable()->constrained('image_folders')->cascadeOnDelete()->comment('상위 폴더 ID');
                $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('폴더 생성 관리자');
                $table->integer('sort_order')->default(0)->comment('표시 순서');
                $table->timestamps();
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('image_folders');
        }
    };

---

### 6.2 `images` (중앙 집중형 미디어 이미지 테이블)
* **테이블 설명**: 물리 파일 메타데이터, SHA-256 해시 중복 차단, 뷰티샵 소유권(`shop_id`) 및 공개 범위(`visibility`) 격리 통합 관리
* **관련 마이그레이션**: `database/migrations/2026_01_01_000002_create_images_table.php`

| 번호 | PK/FK | 컬럼명 | 타입 | Null | 기본값 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- |
| 1 | PK | id | Bigint (unsigned) | N | Auto | 이미지 고유 식별 번호 |
| 2 | FK | folder_id | Bigint (unsigned) | Y | null | 소속 디렉터리 식별 번호 (`image_folders.id` 참조, NullOnDelete) |
| 3 | FK | shop_id | Bigint (unsigned) | Y | null | 소유 뷰티샵 식별 번호 (`shops.id` 참조, NullOnDelete, `null`인 경우 최고관리자 공용 소유) |
| 4 | FK | uploaded_by | Bigint (unsigned) | Y | null | 업로더 유저 식별 번호 (`users.id` 참조, NullOnDelete) |
| 5 | - | visibility | String(20) | N | public | 이미지 공개 범위 (`public`: 공용/공개, `private`: 프라이빗/매장 전용 격리) |
| 6 | - | disk | String(50) | N | public | 파일 저장소 드라이버 (`public`, `s3` 등) |
| 7 | - | path | String(255) | N | - | 스토리지 내 상대 경로 (예: `images/2026/09/uuid.webp`) |
| 8 | - | filename | String(255) | N | - | 디스크에 저장된 물리적 파일명 |
| 9 | - | original_name | String(255) | N | - | 업로드 당시 사용자의 원본 파일명 |
| 10 | - | mime_type | String(50) | Y | null | 파일 바이너리 MIME 타입 (`image/jpeg`, `image/webp` 등) |
| 11 | - | size | Bigint (unsigned) | N | - | 파일 용량 (Byte 단위) |
| 12 | - | width | Integer (unsigned) | Y | null | 이미지 원본 가로 해상도 (Pixel 단위) |
| 13 | - | height | Integer (unsigned) | Y | null | 이미지 원본 세로 해상도 (Pixel 단위) |
| 14 | - | hash | String(64) | N | - | 무결성 검증 및 중복 차단용 SHA-256 고유 해시값 (Unique) |
| 15 | - | title | String(255) | Y | null | 관리자용 이미지 제목 및 캡션 |
| 16 | - | alt_text | String(255) | Y | null | 웹 접근성 및 검색 엔진 최적화용 대체 텍스트(SEO) |
| 17 | - | description | Text | Y | null | 상세 설명 및 관리자 메모 |
| 18 | - | tags | Json | Y | null | 라이브러리 검색 및 카테고리 태그 모음 (JSON 배열) |
| 19 | - | usage_count | Integer (unsigned) | N | 0 | 연결된 엔티티 수 캐시 (`imageables` 관계 수 동기화, 인덱스) |
| 20 | - | created_at | Timestamp | Y | null | 최초 업로드 등록 일시 |
| 21 | - | updated_at | Timestamp | Y | null | 메타데이터 수정 일시 |

#### `images` 마이그레이션 완성 코드

    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;

    return new class extends Migration
    {
        public function up(): void
        {
            Schema::create('images', function (Blueprint $table) {$table->id();

                // 1. 분류, 소유권 및 권한 격리
                $table->foreignId('folder_id')->nullable()->constrained('image_folders')->nullOnDelete()->comment('소속 이미지 폴더 ID');
                $table->foreignId('shop_id')->nullable()->constrained('shops')->nullOnDelete()->comment('소유 뷰티샵 ID (null인 경우 최고관리자 공용 소유)');
                $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete()->comment('업로더 유저 ID');
                $table->string('visibility', 20)->default('public')->comment('공개 범위 (public: 공용/공개, private: 매장전용)');

                // 2. 파일 시스템 정보
                $table->string('disk', 50)->default('public')->comment('파일 저장소 드라이버');
                $table->string('path')->comment('스토리지 내 상대 경로 (storage/...)');
                $table->string('filename')->comment('디스크에 저장된 물리적 파일명');
                $table->string('original_name')->comment('사용자가 업로드한 원본 파일명');
                $table->string('mime_type', 50)->nullable()->comment('파일 바이너리 MIME 타입');
                $table->unsignedBigInteger('size')->comment('파일 용량 (Byte 단위)');
                $table->unsignedInteger('width')->nullable()->comment('이미지 가로 해상도(px)');
                $table->unsignedInteger('height')->nullable()->comment('이미지 세로 해상도(px)');

                // 3. 무결성 및 중복 방지
                $table->string('hash', 64)->unique()->comment('중복 차단용 SHA-256 고유 해시값');

                // 4. SEO 및 메타데이터
                $table->string('title')->nullable()->comment('관리자용 이미지 제목');
                $table->string('alt_text')->nullable()->comment('검색 엔진 노출용 대체 텍스트(SEO)');
                $table->text('description')->nullable()->comment('이미지 상세 설명 및 캡션');
                $table->json('tags')->nullable()->comment('운영용 검색 키워드 모음 (JSON)');

                // 5. 사용량 캐시 (고아 이미지 선별 성능용)
                $table->unsignedInteger('usage_count')->default(0)->index()->comment('연결된 엔티티 수');

                $table->timestamps();
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('images');
        }
    };

---

### 6.3 `imageables` (다형성 연계 피벗 테이블)
* **테이블 설명**: 샵, 시술 프로그램, 제품 등 여러 도메인 모델과 `images` 테이블 간 다대다(M:N) 매핑
* **관련 마이그레이션**: `database/migrations/2026_01_01_000003_create_imageables_table.php`

| 번호 | PK/FK | 컬럼명 | 타입 | Null | 기본값 | 설명 및 상태값 |
| :---: | :---: | :--- | :--- | :---: | :---: | :--- |
| 1 | PK | id | Bigint (unsigned) | N | Auto | 연계 매핑 고유 식별 번호 |
| 2 | FK | image_id | Bigint (unsigned) | N | - | 대상 이미지 식별 번호 (`images.id` 참조, Cascade) |
| 3 | - | imageable_type | String(255) | N | - | 연계 대상 모델 클래스명 (예: `App\Models\Shop`, `App\Models\Program`) |
| 4 | - | imageable_id | Bigint (unsigned) | N | - | 연계 대상 엔티티 기본키 (PK) |
| 5 | - | sort_order | Integer | N | 0 | 노출 순서 정렬 (갤러리 순서 변경용, 오름차순) |
| 6 | - | created_at | Timestamp | Y | null | 관계 생성 일시 |
| 7 | - | updated_at | Timestamp | Y | null | 순서 등 관계 수정 일시 |

#### `imageables` 마이그레이션 완성 코드

    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;

    return new class extends Migration
    {
        public function up(): void
        {
            Schema::create('imageables', function (Blueprint $table) {$table->id()->comment('이미지 다형성 연계 고유 식별 번호');
                
                $table->foreignId('image_id')
                      ->constrained('images')
                      ->cascadeOnDelete()
                      ->comment('연계 대상 이미지 식별 번호 (images.id 참조)');

                // morphs() 메서드가 자동으로 imageable_type과 imageable_id 컬럼 및 복합 인덱스를 생성합니다.
                $table->morphs('imageable');

                $table->integer('sort_order')
                      ->default(0)
                      ->comment('엔티티 내 이미지 노출 정렬 순서 (오름차순)');

                $table->timestamps();
            });
        }

        public function down(): void
        {
            Schema::dropIfExists('imageables');
        }
    };
    