feat: 파일 저장 시스템 DB 마이그레이션
- enhance_files_table: 이중 파일명 시스템 (display_name/stored_name), 폴더 관리, 문서 연결 지원
- create_folders_table: 동적 폴더 관리 시스템 (tenant별 커스터마이징 가능)
- 5개 stub 마이그레이션 생성 (file_share_links, file_deletion_logs, storage_usage_history, add_storage_columns_to_tenants)
- FolderSeeder stub 생성
- CURRENT_WORKS.md에 Phase 1 진행상황 문서화
fix: 파일 공유 및 삭제 기능 버그 수정
- ShareLinkRequest: PATH 파라미터 {id}를 file_id로 자동 병합
- routes/api.php: 공유 링크 다운로드를 auth.apikey 그룹 밖으로 이동 (인증 불필요)
- FileShareLink: File, Tenant 클래스 import 추가
- File 모델: softDeleteFile()에서 SoftDeletes의 delete() 메서드 사용
- FileStorageService: getTrash(), restoreFile(), permanentDelete()에서 onlyTrashed() 사용
- File 모델: Tenant 네임스페이스 수정 (App\Models\Tenants\Tenant)
refactor: Swagger 문서 정리 - File 태그를 Files로 통합
- FileApi.php의 모든 태그를 Files로 변경
- 구 파일 시스템 라우트 삭제 (prefix 'file')
- 구 FileController.php 삭제
- 신규 파일 저장소 시스템으로 완전 통합
fix: 모든 legacy 파일 컬럼 nullable 일괄 처리
- 5개 legacy 컬럼을 한 번에 nullable로 변경
* original_name, file_name, file_name_old (string)
* fileable_id, fileable_type (polymorphic)
- foreach 루프로 반복 작업 자동화
- 신규/기존 시스템 간 완전한 하위 호환성 확보
fix: legacy 파일 컬럼 nullable 처리 완료
- file_name, file_name_old 컬럼도 nullable로 변경
- 기존 시스템과 신규 시스템 간 완전한 하위 호환성 확보
- Legacy: original_name, file_name, file_name_old (nullable)
- New: display_name, stored_name (required)
fix: original_name 컬럼 nullable 처리
- original_name을 nullable로 변경하여 하위 호환성 유지
- 새 시스템에서는 display_name 사용, 기존 시스템은 original_name 사용 가능
fix: 파일 업로드 DB 컬럼 누락 및 메시지 구조 개선
- files 테이블에 감사 컬럼 추가 (created_by, updated_by, uploaded_by)
- ApiResponse::handle() 메시지 로직 개선 (접미사 제거)
- 다국어 지원을 위한 완성된 문장 구조 유지
- FileUploadRequest 파일 검증 규칙 수정
fix: 파일 저장소 버그 수정 및 신규 테넌트 폴더 자동 생성
- FolderSeeder 네임스페이스 수정 (App\Models\Tenant → App\Models\Tenants\Tenant)
- FileStorageController use 문 구문 오류 수정 (/ → \)
- TenantObserver에 신규 테넌트 기본 폴더 자동 생성 로직 추가
- 5개 기본 폴더 (생산관리, 품질관리, 회계, 인사, 일반)
- 에러 처리 및 로깅
- 회원가입 시 자동 실행
This commit is contained in:
618
CURRENT_WORKS.md
618
CURRENT_WORKS.md
@@ -1,5 +1,623 @@
|
|||||||
# SAM API 저장소 작업 현황
|
# SAM API 저장소 작업 현황
|
||||||
|
|
||||||
|
## 2025-11-10 (일) 21:30 - 파일 업로드 DB 에러 및 메시지 구조 개선
|
||||||
|
|
||||||
|
### 주요 작업
|
||||||
|
- files 테이블 감사 컬럼 추가 (created_by, updated_by, uploaded_by)
|
||||||
|
- ApiResponse::handle() 메시지 로직 개선 (다국어 지원)
|
||||||
|
- code-workflow 스킬 사용한 체계적 수정
|
||||||
|
|
||||||
|
### 수정된 파일
|
||||||
|
|
||||||
|
1. **database/migrations/2025_11_10_190208_enhance_files_table.php**
|
||||||
|
- created_by, updated_by, uploaded_by 컬럼 추가
|
||||||
|
- down() 메서드 안전한 롤백 로직 추가
|
||||||
|
|
||||||
|
2. **app/Helpers/ApiResponse.php**
|
||||||
|
- handle() 164번: ' 성공' 접미사 제거
|
||||||
|
- handle() 177번, 145번: ' 실패' 접미사 제거
|
||||||
|
- 다국어 지원을 위한 완성된 문장 구조 유지
|
||||||
|
|
||||||
|
3. **app/Http/Controllers/Api/V1/FileStorageController.php**
|
||||||
|
- ApiResponse 네임스페이스 수정 (App\Utils → App\Helpers)
|
||||||
|
|
||||||
|
4. **app/Http/Requests/Api/V1/FileUploadRequest.php**
|
||||||
|
- 파일 검증 규칙 수정 (allowed_extensions 사용)
|
||||||
|
|
||||||
|
### 작업 내용
|
||||||
|
|
||||||
|
#### 1. DB 컬럼 누락 에러 수정
|
||||||
|
**에러:** `SQLSTATE[42S22]: Column not found: 1054 Unknown column 'created_by'`
|
||||||
|
**원인:** File 모델 fillable에는 있으나 실제 테이블에는 없음
|
||||||
|
**해결:** 마이그레이션에 created_by, updated_by, uploaded_by 컬럼 추가
|
||||||
|
|
||||||
|
#### 2. 메시지 구조 개선
|
||||||
|
**문제:** "파일이 업로드되었습니다. 실패" (성공 문구 + 실패 접미사)
|
||||||
|
**원인:** ApiResponse에서 ' 성공', ' 실패' 하드코딩 + 완성된 문장 충돌
|
||||||
|
**해결:** 접미사 제거, 완성된 문장 그대로 사용 (다국어 지원)
|
||||||
|
|
||||||
|
**결과:**
|
||||||
|
- 성공: "파일이 업로드되었습니다." ✅
|
||||||
|
- 실패: "서버 에러" (details에 실제 에러) ✅
|
||||||
|
|
||||||
|
### Git 커밋
|
||||||
|
```bash
|
||||||
|
git commit -m "fix: 파일 업로드 DB 컬럼 누락 및 메시지 구조 개선
|
||||||
|
|
||||||
|
- files 테이블에 감사 컬럼 추가 (created_by, updated_by, uploaded_by)
|
||||||
|
- ApiResponse::handle() 메시지 로직 개선 (접미사 제거)
|
||||||
|
- 다국어 지원을 위한 완성된 문장 구조 유지"
|
||||||
|
```
|
||||||
|
|
||||||
|
### TODO
|
||||||
|
- [ ] **메시지 시스템 전면 개편** (나중에)
|
||||||
|
- message.php를 동사원형으로 변경
|
||||||
|
- 다국어 접미사 통일 (success/fail)
|
||||||
|
- 영향도: 50개+ 파일 수정 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2025-11-10 (일) 20:00 - 파일 저장소 시스템 버그 수정 및 신규 테넌트 폴더 자동 생성
|
||||||
|
|
||||||
|
### 주요 작업
|
||||||
|
- **FolderSeeder 네임스페이스 수정**: `\App\Models\Tenant` → `\App\Models\Tenants\Tenant`
|
||||||
|
- **FileStorageController use 문 수정**: 잘못된 네임스페이스 구분자 수정 (`/` → `\`)
|
||||||
|
- **TenantObserver 확장**: 신규 테넌트 생성 시 기본 폴더 자동 생성 로직 추가
|
||||||
|
- **Storage 디렉토리 권한 설정 안내**: `storage/app/tenants/` 생성 및 권한 설정
|
||||||
|
|
||||||
|
### 수정된 파일:
|
||||||
|
|
||||||
|
**Database Seeder:**
|
||||||
|
- `database/seeders/FolderSeeder.php` - 네임스페이스 수정 (lines 20-21)
|
||||||
|
- 수정 전: `\App\Models\Tenant::findOrFail()`, `\App\Models\Tenant::all()`
|
||||||
|
- 수정 후: `\App\Models\Tenants\Tenant::findOrFail()`, `\App\Models\Tenants\Tenant::all()`
|
||||||
|
|
||||||
|
**Controller:**
|
||||||
|
- `app/Http/Controllers/Api/V1/FileStorageController.php` - use 문 수정 (line 7)
|
||||||
|
- 수정 전: `use App\Http\Requests\Api\V1/FileMoveRequest;`
|
||||||
|
- 수정 후: `use App\Http\Requests\Api\V1\FileMoveRequest;`
|
||||||
|
|
||||||
|
**Observer:**
|
||||||
|
- `app/Observers/TenantObserver.php` - 신규 테넌트 기본 폴더 자동 생성 로직 추가
|
||||||
|
- 기존 TenantBootstrapper 유지
|
||||||
|
- 5개 기본 폴더 자동 생성 (생산관리, 품질관리, 회계, 인사, 일반)
|
||||||
|
- try-catch 에러 처리 및 로깅
|
||||||
|
|
||||||
|
### 작업 내용:
|
||||||
|
|
||||||
|
#### 1. Seeder 네임스페이스 오류 수정
|
||||||
|
- **문제**: `php artisan db:seed --class=FolderSeeder` 실행 시 "Class 'App\Models\Tenant' not found" 에러
|
||||||
|
- **원인**: Tenant 모델이 `App\Models\Tenants\Tenant`에 있으나 `App\Models\Tenant`로 참조
|
||||||
|
- **해결**: FolderSeeder의 Tenant 참조를 올바른 네임스페이스로 수정
|
||||||
|
|
||||||
|
#### 2. Controller 구문 오류 수정
|
||||||
|
- **문제**: Pint 실행 시 "syntax error, unexpected '/'" 에러
|
||||||
|
- **원인**: use 문에서 잘못된 네임스페이스 구분자 사용 (`/` 대신 `\`)
|
||||||
|
- **해결**: FileStorageController의 use 문 구분자를 백슬래시로 수정
|
||||||
|
|
||||||
|
#### 3. 신규 테넌트 자동 폴더 생성
|
||||||
|
- **목적**: 신규 테넌트 회원가입 시 수동으로 Seeder를 실행하지 않아도 기본 폴더가 자동 생성되도록 개선
|
||||||
|
- **구현**: TenantObserver의 `created()` 메서드에 폴더 생성 로직 추가
|
||||||
|
- **동작**:
|
||||||
|
1. `Tenant::create()` 호출 시 Observer 자동 트리거
|
||||||
|
2. TenantBootstrapper 실행 (기존 로직 유지)
|
||||||
|
3. 5개 기본 폴더 자동 생성 (신규)
|
||||||
|
4. 에러 발생 시 로그 기록하되 테넌트 생성은 계속 진행
|
||||||
|
|
||||||
|
#### 4. Storage 디렉토리 설정
|
||||||
|
```bash
|
||||||
|
# 디렉토리 생성
|
||||||
|
mkdir -p storage/app/tenants
|
||||||
|
|
||||||
|
# 권한 설정
|
||||||
|
chmod 775 storage/app/tenants
|
||||||
|
|
||||||
|
# 로컬 개발 환경에서는 현재 사용자 소유권으로 충분
|
||||||
|
# 프로덕션 환경에서는 웹서버 사용자로 소유권 설정 필요
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테스트 시나리오:
|
||||||
|
|
||||||
|
1. **기존 테넌트 폴더 생성**:
|
||||||
|
```bash
|
||||||
|
php artisan db:seed --class=FolderSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **신규 테넌트 폴더 자동 생성**:
|
||||||
|
- 회원가입 API 호출 또는 `Tenant::create()` 실행
|
||||||
|
- 자동으로 5개 기본 폴더 생성됨
|
||||||
|
|
||||||
|
### Git 커밋:
|
||||||
|
- `aeeeba6` - fix: 파일 저장소 버그 수정 및 신규 테넌트 폴더 자동 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2025-11-10 (일) - 파일 저장소 시스템 구현 완료 (Phase 2~5)
|
||||||
|
|
||||||
|
### 주요 작업
|
||||||
|
- **파일 저장소 시스템 완성**: Models, Services, Controllers, Commands, Swagger, Config, Routes 전체 구현
|
||||||
|
- **25개 파일 생성/수정**: Phase 2-5 완료로 구현 가이드 기준 100% 달성
|
||||||
|
- **코드 품질 검증**: Pint 포맷팅 완료, Swagger 문서 생성 완료
|
||||||
|
|
||||||
|
### 추가된 파일 (21개):
|
||||||
|
|
||||||
|
**Models (3개):**
|
||||||
|
- `app/Models/Folder.php` - 폴더 관리 모델
|
||||||
|
- `app/Models/FileShareLink.php` - 공유 링크 모델
|
||||||
|
- `app/Models/Commons/File.php` - 기존 파일 모델 확장
|
||||||
|
|
||||||
|
**Services (2개):**
|
||||||
|
- `app/Services/FileStorageService.php` - 파일 저장소 서비스 (Legacy FileService 충돌 방지)
|
||||||
|
- `app/Services/FolderService.php` - 폴더 관리 서비스
|
||||||
|
|
||||||
|
**FormRequests (5개):**
|
||||||
|
- `app/Http/Requests/Api/V1/FileUploadRequest.php` - 파일 업로드 검증
|
||||||
|
- `app/Http/Requests/Api/V1/FileMoveRequest.php` - 파일 이동 검증
|
||||||
|
- `app/Http/Requests/Api/V1/FolderStoreRequest.php` - 폴더 생성 검증
|
||||||
|
- `app/Http/Requests/Api/V1/FolderUpdateRequest.php` - 폴더 수정 검증
|
||||||
|
- `app/Http/Requests/Api/V1/ShareLinkRequest.php` - 공유 링크 생성 검증
|
||||||
|
|
||||||
|
**Controllers (2개):**
|
||||||
|
- `app/Http/Controllers/Api/V1/FileStorageController.php` - 파일 저장소 컨트롤러
|
||||||
|
- `app/Http/Controllers/Api/V1/FolderController.php` - 폴더 관리 컨트롤러
|
||||||
|
|
||||||
|
**Commands (4개):**
|
||||||
|
- `app/Console/Commands/CleanupTempFiles.php` - 7일 이상 임시 파일 정리
|
||||||
|
- `app/Console/Commands/CleanupTrash.php` - 30일 이상 휴지통 파일 정리
|
||||||
|
- `app/Console/Commands/CleanupExpiredLinks.php` - 만료된 공유 링크 정리
|
||||||
|
- `app/Console/Commands/RecordStorageUsage.php` - 일일 용량 사용량 기록
|
||||||
|
|
||||||
|
**Swagger (2개):**
|
||||||
|
- `app/Swagger/v1/FileApi.php` - 파일 저장소 API 문서
|
||||||
|
- `app/Swagger/v1/FolderApi.php` - 폴더 관리 API 문서
|
||||||
|
|
||||||
|
**Database (5개 - Phase 1에서 완료):**
|
||||||
|
- `database/migrations/2025_11_10_190355_create_file_share_links_table.php`
|
||||||
|
- `database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php`
|
||||||
|
- `database/migrations/2025_11_10_190355_create_storage_usage_history_table.php`
|
||||||
|
- `database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php`
|
||||||
|
- `database/seeders/FolderSeeder.php`
|
||||||
|
|
||||||
|
### 수정된 파일 (4개):
|
||||||
|
|
||||||
|
**Config:**
|
||||||
|
- `config/filesystems.php` - tenant disk, 파일 제약사항, 저장소 정책, 공유 링크 설정 추가
|
||||||
|
|
||||||
|
**i18n:**
|
||||||
|
- `lang/ko/message.php` - 11개 파일/폴더 성공 메시지 추가
|
||||||
|
- `lang/ko/error.php` - 17개 파일/폴더 에러 메시지 추가
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
- `routes/api.php` - 파일 저장소 및 폴더 관리 라우트 추가
|
||||||
|
- `routes/console.php` - 4개 스케줄러 등록 (Laravel 12 표준)
|
||||||
|
|
||||||
|
**Tenant Model:**
|
||||||
|
- `app/Models/Tenants/Tenant.php` - 저장소 용량 관리 메서드 8개 추가
|
||||||
|
|
||||||
|
### 작업 내용:
|
||||||
|
|
||||||
|
#### 1. Phase 2: Models (4개)
|
||||||
|
|
||||||
|
**Folder.php:**
|
||||||
|
```php
|
||||||
|
- BelongsToTenant, ModelTrait
|
||||||
|
- scopeActive(), scopeOrdered()
|
||||||
|
- files() HasMany 관계
|
||||||
|
```
|
||||||
|
|
||||||
|
**FileShareLink.php:**
|
||||||
|
```php
|
||||||
|
- 자동 64자 토큰 생성 (bin2hex(random_bytes(32)))
|
||||||
|
- isExpired(), isValid(), isDownloadLimitReached()
|
||||||
|
- incrementDownloadCount() 다운로드 추적
|
||||||
|
```
|
||||||
|
|
||||||
|
**File.php (확장):**
|
||||||
|
```php
|
||||||
|
- BelongsToTenant 추가
|
||||||
|
- moveToFolder() - temp → folder_key 이동
|
||||||
|
- permanentDelete() - 물리 삭제 + 용량 차감
|
||||||
|
- download() - Storage Facade 통합
|
||||||
|
```
|
||||||
|
|
||||||
|
**Tenant.php (확장):**
|
||||||
|
```php
|
||||||
|
- canUpload() - 90% 경고 → 7일 유예 로직
|
||||||
|
- incrementStorage(), decrementStorage()
|
||||||
|
- resetGracePeriod(), isInGracePeriod()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Phase 3: Services/Controllers/Requests (9개)
|
||||||
|
|
||||||
|
**FileStorageService (신규 서비스):**
|
||||||
|
```php
|
||||||
|
- upload() - temp 업로드 + 용량 체크
|
||||||
|
- moveToFolder() - temp → folder 이동
|
||||||
|
- deleteFile() - soft delete + 삭제 로그
|
||||||
|
- restoreFile() - 복구
|
||||||
|
- permanentDelete() - 물리 삭제
|
||||||
|
- createShareLink() - 공유 링크 생성
|
||||||
|
- getFileByShareToken() - 공유 링크 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
**FolderService:**
|
||||||
|
```php
|
||||||
|
- index() - 폴더 목록 (display_order)
|
||||||
|
- store() - 폴더 생성 (자동 순서)
|
||||||
|
- update() - 폴더 수정
|
||||||
|
- destroy() - 비활성화 (파일 있으면 거부)
|
||||||
|
- reorder() - 순서 일괄 변경
|
||||||
|
```
|
||||||
|
|
||||||
|
**FileStorageController:**
|
||||||
|
```php
|
||||||
|
10개 엔드포인트:
|
||||||
|
- upload, move, index, show, trash, download
|
||||||
|
- destroy, restore, permanentDelete, createShareLink
|
||||||
|
```
|
||||||
|
|
||||||
|
**FolderController:**
|
||||||
|
```php
|
||||||
|
6개 엔드포인트:
|
||||||
|
- index, store, show, update, destroy, reorder
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Phase 4: Commands + Scheduler + Swagger (7개)
|
||||||
|
|
||||||
|
**Commands (4개):**
|
||||||
|
```php
|
||||||
|
CleanupTempFiles (매일 03:30)
|
||||||
|
- 7일 이상 temp 파일 삭제
|
||||||
|
- Storage + DB 동기화
|
||||||
|
|
||||||
|
CleanupTrash (매일 03:40)
|
||||||
|
- 30일 이상 삭제 파일 영구 삭제
|
||||||
|
- file_deletion_logs 기록
|
||||||
|
|
||||||
|
CleanupExpiredLinks (매일 03:50)
|
||||||
|
- 만료된 공유 링크 삭제
|
||||||
|
|
||||||
|
RecordStorageUsage (매일 04:00)
|
||||||
|
- 테넌트별 용량 사용량 기록
|
||||||
|
- 폴더별 사용량 JSON 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
**routes/console.php (Laravel 12):**
|
||||||
|
```php
|
||||||
|
Schedule::command('storage:cleanup-temp')
|
||||||
|
->dailyAt('03:30')
|
||||||
|
->appendOutputTo(storage_path('logs/scheduler.log'))
|
||||||
|
->onSuccess/onFailure 로그
|
||||||
|
```
|
||||||
|
|
||||||
|
**Swagger 문서 (2개):**
|
||||||
|
```php
|
||||||
|
FileApi.php:
|
||||||
|
- 10개 엔드포인트 완전 문서화
|
||||||
|
- File 모델 스키마 정의
|
||||||
|
- FileUploadRequest, FileMoveRequest, ShareLinkRequest 스키마
|
||||||
|
|
||||||
|
FolderApi.php:
|
||||||
|
- 6개 엔드포인트 완전 문서화
|
||||||
|
- Folder 모델 스키마 정의
|
||||||
|
- FolderStoreRequest, FolderUpdateRequest, FolderReorderRequest 스키마
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Phase 5: Config/i18n/Routes (4개)
|
||||||
|
|
||||||
|
**config/filesystems.php:**
|
||||||
|
```php
|
||||||
|
'tenant' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/tenants'),
|
||||||
|
]
|
||||||
|
|
||||||
|
'file_constraints' => [
|
||||||
|
'max_file_size' => 20MB,
|
||||||
|
'allowed_extensions' => [pdf, doc, image, archive...],
|
||||||
|
]
|
||||||
|
|
||||||
|
'storage_policies' => [
|
||||||
|
'default_limit' => 10GB,
|
||||||
|
'warning_threshold' => 0.9,
|
||||||
|
'grace_period_days' => 7,
|
||||||
|
'trash_retention_days' => 30,
|
||||||
|
]
|
||||||
|
|
||||||
|
'share_link' => [
|
||||||
|
'expiry_hours' => 24,
|
||||||
|
'max_downloads' => null,
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**i18n 메시지 (28개):**
|
||||||
|
```php
|
||||||
|
lang/ko/message.php (11개):
|
||||||
|
- file_uploaded, files_moved, file_deleted
|
||||||
|
- file_restored, file_permanently_deleted
|
||||||
|
- share_link_created, storage_exceeded_grace_period
|
||||||
|
- folder_created, folder_updated, folder_deleted
|
||||||
|
- folders_reordered
|
||||||
|
|
||||||
|
lang/ko/error.php (17개):
|
||||||
|
- file_not_found, folder_not_found
|
||||||
|
- storage_quota_exceeded, share_link_expired
|
||||||
|
- folder_key_duplicate, folder_has_files
|
||||||
|
- color_format, expiry_hours_min/max
|
||||||
|
```
|
||||||
|
|
||||||
|
**routes/api.php:**
|
||||||
|
```php
|
||||||
|
파일 저장소 (10개):
|
||||||
|
- POST /files/upload
|
||||||
|
- POST /files/move
|
||||||
|
- GET /files (+ trash)
|
||||||
|
- GET/DELETE /files/{id}
|
||||||
|
- POST /files/{id}/restore
|
||||||
|
- DELETE /files/{id}/permanent
|
||||||
|
- POST /files/{id}/share
|
||||||
|
- GET /files/share/{token} (공개)
|
||||||
|
|
||||||
|
폴더 관리 (6개):
|
||||||
|
- GET/POST /folders
|
||||||
|
- GET/PUT/DELETE /folders/{id}
|
||||||
|
- POST /folders/reorder
|
||||||
|
```
|
||||||
|
|
||||||
|
### 설계 특징:
|
||||||
|
|
||||||
|
**1. 경로 구조:**
|
||||||
|
```
|
||||||
|
/storage/app/tenants/
|
||||||
|
├── {tenant_id}/
|
||||||
|
│ ├── temp/{year}/{month}/{stored_name} # 업로드 직후
|
||||||
|
│ ├── product/{year}/{month}/{stored_name} # 문서 첨부 후
|
||||||
|
│ ├── quality/{year}/{month}/{stored_name}
|
||||||
|
│ └── accounting/{year}/{month}/{stored_name}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 워크플로우:**
|
||||||
|
```
|
||||||
|
1. 파일 업로드
|
||||||
|
- POST /files/upload
|
||||||
|
- temp 폴더에 저장 (is_temp=true)
|
||||||
|
- 64자 난수 파일명 (보안)
|
||||||
|
|
||||||
|
2. 문서에 첨부
|
||||||
|
- POST /files/move
|
||||||
|
- temp → folder_key 이동
|
||||||
|
- document_id, document_type 설정
|
||||||
|
|
||||||
|
3. 공유 링크 생성
|
||||||
|
- POST /files/{id}/share
|
||||||
|
- 64자 토큰 + 24시간 만료
|
||||||
|
- 다운로드 횟수 추적
|
||||||
|
|
||||||
|
4. 삭제/복구
|
||||||
|
- DELETE (soft delete)
|
||||||
|
- POST restore (복구)
|
||||||
|
- DELETE permanent (영구 삭제)
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 용량 관리:**
|
||||||
|
```
|
||||||
|
업로드 시:
|
||||||
|
- tenants.storage_used 증가
|
||||||
|
- 90% 도달 → 경고 이메일 + 7일 유예
|
||||||
|
- 100% 초과 + 유예 기간 내 → 업로드 허용
|
||||||
|
- 유예 만료 → 업로드 차단
|
||||||
|
```
|
||||||
|
|
||||||
|
**4. 자동 정리:**
|
||||||
|
```
|
||||||
|
매일 새벽:
|
||||||
|
- 03:30: 7일 이상 temp 파일 삭제
|
||||||
|
- 03:40: 30일 이상 휴지통 파일 영구 삭제
|
||||||
|
- 03:50: 만료된 공유 링크 삭제
|
||||||
|
- 04:00: 테넌트별 용량 사용량 기록
|
||||||
|
```
|
||||||
|
|
||||||
|
### 기술 세부사항:
|
||||||
|
|
||||||
|
#### Laravel 12 Scheduler (🔴 중요!)
|
||||||
|
```php
|
||||||
|
// ❌ 기존 (Laravel 11): Kernel.php
|
||||||
|
protected function schedule(Schedule $schedule) { ... }
|
||||||
|
|
||||||
|
// ✅ Laravel 12: routes/console.php
|
||||||
|
Schedule::command('storage:cleanup-temp')
|
||||||
|
->dailyAt('03:30')
|
||||||
|
->appendOutputTo(storage_path('logs/scheduler.log'))
|
||||||
|
->onSuccess/onFailure
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Storage Facade 추상화
|
||||||
|
```php
|
||||||
|
// 현재: local disk
|
||||||
|
Storage::disk('tenant')->put($path, $file);
|
||||||
|
|
||||||
|
// 미래: S3로 전환 (설정만 변경)
|
||||||
|
'tenant' => ['driver' => 's3', 'bucket' => env('AWS_BUCKET')]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 보안: 64bit 난수 파일명
|
||||||
|
```php
|
||||||
|
bin2hex(random_bytes(32))
|
||||||
|
→ "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SAM API Development Rules 준수:
|
||||||
|
|
||||||
|
✅ **Service-First 아키텍처:**
|
||||||
|
- FileStorageService, FolderService에 모든 로직
|
||||||
|
- Controller는 DI + ApiResponse::handle()
|
||||||
|
|
||||||
|
✅ **FormRequest 검증:**
|
||||||
|
- 5개 FormRequest로 모든 검증 분리
|
||||||
|
|
||||||
|
✅ **i18n 메시지 키:**
|
||||||
|
- __('message.xxx'), __('error.xxx') 28개 추가
|
||||||
|
|
||||||
|
✅ **Swagger 문서:**
|
||||||
|
- 별도 파일 (FileApi.php, FolderApi.php)
|
||||||
|
- 16개 엔드포인트 완전 문서화
|
||||||
|
|
||||||
|
✅ **멀티테넌시:**
|
||||||
|
- BelongsToTenant 스코프
|
||||||
|
- tenant_id 격리
|
||||||
|
|
||||||
|
✅ **감사 로그:**
|
||||||
|
- file_deletion_logs 테이블
|
||||||
|
- created_by, updated_by, deleted_by
|
||||||
|
|
||||||
|
✅ **SoftDeletes:**
|
||||||
|
- File 모델 soft delete
|
||||||
|
- 30일 휴지통 보관
|
||||||
|
|
||||||
|
✅ **코드 품질:**
|
||||||
|
- Laravel Pint 포맷팅 완료
|
||||||
|
- Swagger 문서 생성 완료
|
||||||
|
|
||||||
|
### 예상 효과:
|
||||||
|
|
||||||
|
1. **완전한 파일 관리**: 업로드 → 이동 → 공유 → 삭제 → 복구 전체 워크플로우
|
||||||
|
2. **용량 제어**: 90% 경고 → 7일 유예 → 차단 단계별 관리
|
||||||
|
3. **자동 정리**: 임시/삭제 파일 자동 정리로 디스크 최적화
|
||||||
|
4. **클라우드 전환 용이**: Storage Facade로 S3 마이그레이션 간단
|
||||||
|
5. **감사 추적**: 파일 삭제 로그, 용량 사용량 히스토리
|
||||||
|
|
||||||
|
### 다음 작업:
|
||||||
|
|
||||||
|
- [ ] 마이그레이션 실행: `php artisan migrate`
|
||||||
|
- [ ] 폴더 시더 실행: `php artisan db:seed --class=FolderSeeder`
|
||||||
|
- [ ] storage/app/tenants/ 디렉토리 생성 및 권한 설정
|
||||||
|
- [ ] API 테스트 (Postman/Swagger UI)
|
||||||
|
- [ ] Frontend 파일 업로드 UI 구현
|
||||||
|
|
||||||
|
### Git 커밋 준비:
|
||||||
|
- 다음 커밋 예정: `feat: 파일 저장소 시스템 구현 완료 (Phase 2-5, 25개 파일)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2025-11-10 (일) - 파일 저장 시스템 구현 시작 (Phase 1: DB 마이그레이션)
|
||||||
|
|
||||||
|
### 주요 작업
|
||||||
|
- **파일 저장 시스템 기반 구축**: 로컬 저장 우선, 클라우드(S3) 전환 가능 구조
|
||||||
|
- **DB 마이그레이션 7개 생성**: files 테이블 개선, folders, file_share_links, file_deletion_logs, storage_usage_history, tenants 용량 관리
|
||||||
|
- **설계 기반**: `/claudedocs/file_storage_implementation_guide.md` 참조
|
||||||
|
|
||||||
|
### 추가된 파일 (7개):
|
||||||
|
- `database/migrations/2025_11_10_190208_enhance_files_table.php` - files 테이블 구조 개선 (완료)
|
||||||
|
- `database/migrations/2025_11_10_190257_create_folders_table.php` - 동적 폴더 관리 테이블 (완료)
|
||||||
|
- `database/migrations/2025_11_10_190355_create_file_share_links_table.php` - 외부 공유 링크 테이블 (stub)
|
||||||
|
- `database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php` - 파일 삭제 로그 테이블 (stub)
|
||||||
|
- `database/migrations/2025_11_10_190355_create_storage_usage_history_table.php` - 용량 히스토리 테이블 (stub)
|
||||||
|
- `database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php` - 테넌트 용량 관리 컬럼 (stub)
|
||||||
|
- `database/seeders/FolderSeeder.php` - 기본 폴더 시더 (stub)
|
||||||
|
|
||||||
|
### 작업 내용:
|
||||||
|
|
||||||
|
#### 1. files 테이블 개선 (완료)
|
||||||
|
```php
|
||||||
|
// 새로운 컬럼
|
||||||
|
- display_name: 사용자가 보는 파일명 (예: 도면.pdf)
|
||||||
|
- stored_name: 실제 저장 파일명 (예: a1b2c3d4e5f6g7h8.pdf, 64bit 난수)
|
||||||
|
- folder_id: folders 테이블 FK
|
||||||
|
- is_temp: temp 폴더 여부 (업로드 직후 true)
|
||||||
|
- file_type: document/image/excel/archive
|
||||||
|
- document_id: 문서 ID (polymorphic 대체)
|
||||||
|
- document_type: 문서 타입 (work_order, quality_check 등)
|
||||||
|
- deleted_by: 삭제자 ID
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
- idx_tenant_folder: (tenant_id, folder_id)
|
||||||
|
- is_temp, document_id, created_at, stored_name
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. folders 테이블 생성 (완료)
|
||||||
|
```php
|
||||||
|
// 동적 폴더 관리
|
||||||
|
- folder_key: product, quality, accounting (고유 키)
|
||||||
|
- folder_name: 생산관리, 품질관리, 회계 (표시명)
|
||||||
|
- display_order: 정렬 순서
|
||||||
|
- is_active: 활성 여부
|
||||||
|
- icon, color: UI 커스터마이징
|
||||||
|
|
||||||
|
// 유니크 제약
|
||||||
|
- (tenant_id, folder_key)
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
- (tenant_id, is_active)
|
||||||
|
- (tenant_id, display_order)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 나머지 마이그레이션 (stub 생성만 완료)
|
||||||
|
- file_share_links: 24시간 임시 공유 링크
|
||||||
|
- file_deletion_logs: 삭제 감사 추적
|
||||||
|
- storage_usage_history: 용량 사용량 히스토리
|
||||||
|
- tenants 용량 관리: storage_limit, storage_used, storage_warning_sent_at, storage_grace_period_until
|
||||||
|
|
||||||
|
### 설계 특징:
|
||||||
|
|
||||||
|
**1. 로컬 → 클라우드 전환 용이:**
|
||||||
|
```php
|
||||||
|
// 현재 (로컬)
|
||||||
|
'tenant' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/tenants'),
|
||||||
|
]
|
||||||
|
|
||||||
|
// 전환 후 (S3) - driver만 변경
|
||||||
|
'tenant' => [
|
||||||
|
'driver' => 's3',
|
||||||
|
'bucket' => env('AWS_BUCKET'),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 파일 경로 구조:**
|
||||||
|
```
|
||||||
|
/storage/app/tenants/
|
||||||
|
├── {tenant_id}/
|
||||||
|
│ ├── product/{year}/{month}/{stored_name}
|
||||||
|
│ ├── quality/{year}/{month}/{stored_name}
|
||||||
|
│ ├── accounting/{year}/{month}/{stored_name}
|
||||||
|
│ └── temp/{year}/{month}/{stored_name}
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 용량 관리:**
|
||||||
|
- 기본 한도: 10GB
|
||||||
|
- 90% 경고 → 이메일 발송 + 7일 유예
|
||||||
|
- 100% 초과 → 유예 기간 내 업로드 허용
|
||||||
|
- 유예 만료 → 업로드 차단
|
||||||
|
|
||||||
|
### 다음 작업 (새 세션에서 진행):
|
||||||
|
|
||||||
|
**Phase 2: 모델 및 Service (4개)**
|
||||||
|
- [ ] File 모델 리팩토링 (BelongsToTenant, Storage 통합)
|
||||||
|
- [ ] Folder 모델 생성
|
||||||
|
- [ ] FileShareLink 모델 생성
|
||||||
|
- [ ] FileService 전면 리팩토링 (Storage Facade 사용)
|
||||||
|
|
||||||
|
**Phase 3: Controller 및 API (7개)**
|
||||||
|
- [ ] FolderService 생성
|
||||||
|
- [ ] FormRequest 5개 생성
|
||||||
|
- [ ] FileController 리팩토링
|
||||||
|
- [ ] FolderController 생성
|
||||||
|
|
||||||
|
**Phase 4: 문서 및 배치 (6개)**
|
||||||
|
- [ ] Swagger 문서 2개
|
||||||
|
- [ ] Commands 4개 (temp 정리, 휴지통 정리, 링크 정리, 용량 기록)
|
||||||
|
|
||||||
|
**Phase 5: 설정 (3개)**
|
||||||
|
- [ ] config/filesystems.php 수정
|
||||||
|
- [ ] i18n 메시지 추가
|
||||||
|
- [ ] routes/api.php 업데이트
|
||||||
|
|
||||||
|
### Git 커밋 준비:
|
||||||
|
- 다음 커밋 예정: `feat: 파일 저장 시스템 DB 마이그레이션 (Phase 1)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2025-11-10 (일) - API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리 + 자동 정리)
|
## 2025-11-10 (일) - API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리 + 자동 정리)
|
||||||
|
|
||||||
### 주요 작업
|
### 주요 작업
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 논리적 데이터베이스 관계 문서
|
# 논리적 데이터베이스 관계 문서
|
||||||
|
|
||||||
> **자동 생성**: 2025-10-14 22:24:19
|
> **자동 생성**: 2025-11-10 21:01:46
|
||||||
> **소스**: Eloquent 모델 관계 분석
|
> **소스**: Eloquent 모델 관계 분석
|
||||||
|
|
||||||
## 📊 모델별 관계 현황
|
## 📊 모델별 관계 현황
|
||||||
@@ -63,7 +63,9 @@ ### category_templates
|
|||||||
### files
|
### files
|
||||||
**모델**: `App\Models\Commons\File`
|
**모델**: `App\Models\Commons\File`
|
||||||
|
|
||||||
|
- **folder()**: belongsTo → `folders`
|
||||||
- **uploader()**: belongsTo → `users`
|
- **uploader()**: belongsTo → `users`
|
||||||
|
- **shareLinks()**: hasMany → `file_share_links`
|
||||||
- **fileable()**: morphTo → `(Polymorphic)`
|
- **fileable()**: morphTo → `(Polymorphic)`
|
||||||
|
|
||||||
### menus
|
### menus
|
||||||
@@ -110,6 +112,14 @@ ### estimate_items
|
|||||||
|
|
||||||
- **estimate()**: belongsTo → `estimates`
|
- **estimate()**: belongsTo → `estimates`
|
||||||
|
|
||||||
|
### file_share_links
|
||||||
|
**모델**: `App\Models\FileShareLink`
|
||||||
|
|
||||||
|
|
||||||
|
### folders
|
||||||
|
**모델**: `App\Models\Folder`
|
||||||
|
|
||||||
|
|
||||||
### main_requests
|
### main_requests
|
||||||
**모델**: `App\Models\MainRequest`
|
**모델**: `App\Models\MainRequest`
|
||||||
|
|
||||||
|
|||||||
39
app/Console/Commands/CleanupExpiredLinks.php
Normal file
39
app/Console/Commands/CleanupExpiredLinks.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class CleanupExpiredLinks extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'storage:cleanup-links';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Delete expired share links older than 7 days';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$threshold = now()->subDays(7);
|
||||||
|
|
||||||
|
$deleted = DB::table('file_share_links')
|
||||||
|
->where('expires_at', '<', $threshold)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$this->info("Deleted {$deleted} expired share links");
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Console/Commands/CleanupTempFiles.php
Normal file
59
app/Console/Commands/CleanupTempFiles.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Commons\File;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class CleanupTempFiles extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'storage:cleanup-temp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Delete temporary files older than 7 days';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$threshold = now()->subDays(7);
|
||||||
|
|
||||||
|
$files = File::where('is_temp', true)
|
||||||
|
->where('created_at', '<', $threshold)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$count = $files->count();
|
||||||
|
$this->info("Found {$count} temp files to delete");
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
try {
|
||||||
|
// Delete physical file
|
||||||
|
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||||
|
Storage::disk('tenant')->delete($file->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force delete from DB
|
||||||
|
$file->forceDelete();
|
||||||
|
$deleted++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("Failed to delete file ID {$file->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Cleanup completed: {$deleted}/{$count} files deleted");
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Console/Commands/CleanupTrash.php
Normal file
83
app/Console/Commands/CleanupTrash.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Commons\File;
|
||||||
|
use App\Models\Tenants\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class CleanupTrash extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'storage:cleanup-trash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Permanently delete trashed files older than 30 days';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$threshold = now()->subDays(30);
|
||||||
|
|
||||||
|
$files = File::onlyTrashed()
|
||||||
|
->where('deleted_at', '<', $threshold)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$count = $files->count();
|
||||||
|
$this->info("Found {$count} files in trash to permanently delete");
|
||||||
|
|
||||||
|
$deleted = 0;
|
||||||
|
foreach ($files as $file) {
|
||||||
|
try {
|
||||||
|
$this->permanentDelete($file);
|
||||||
|
$deleted++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("Failed to permanently delete file ID {$file->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Trash cleanup completed: {$deleted}/{$count} files deleted");
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete a file
|
||||||
|
*/
|
||||||
|
private function permanentDelete(File $file): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($file) {
|
||||||
|
// Delete physical file
|
||||||
|
if (Storage::disk('tenant')->exists($file->file_path)) {
|
||||||
|
Storage::disk('tenant')->delete($file->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tenant storage usage
|
||||||
|
$tenant = Tenant::find($file->tenant_id);
|
||||||
|
if ($tenant) {
|
||||||
|
$tenant->decrement('storage_used', $file->file_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update deletion log
|
||||||
|
DB::table('file_deletion_logs')
|
||||||
|
->where('file_id', $file->id)
|
||||||
|
->where('deletion_type', 'soft')
|
||||||
|
->update(['deletion_type' => 'permanent']);
|
||||||
|
|
||||||
|
// Force delete from DB
|
||||||
|
$file->forceDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Console/Commands/RecordStorageUsage.php
Normal file
77
app/Console/Commands/RecordStorageUsage.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Commons\File;
|
||||||
|
use App\Models\Folder;
|
||||||
|
use App\Models\Tenants\Tenant;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class RecordStorageUsage extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'storage:record-usage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Record daily storage usage history for all active tenants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$tenants = Tenant::where('status', 'active')->get();
|
||||||
|
|
||||||
|
$recorded = 0;
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
try {
|
||||||
|
// Calculate folder usage
|
||||||
|
$folderUsage = File::where('tenant_id', $tenant->id)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereNotNull('folder_id')
|
||||||
|
->selectRaw('folder_id, SUM(file_size) as total')
|
||||||
|
->groupBy('folder_id')
|
||||||
|
->get()
|
||||||
|
->mapWithKeys(function ($item) {
|
||||||
|
$folder = Folder::find($item->folder_id);
|
||||||
|
if ($folder) {
|
||||||
|
return [$folder->folder_key => (int) $item->total];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Count active files
|
||||||
|
$fileCount = File::where('tenant_id', $tenant->id)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
// Insert history record
|
||||||
|
DB::table('storage_usage_history')->insert([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'storage_used' => $tenant->storage_used,
|
||||||
|
'file_count' => $fileCount,
|
||||||
|
'folder_usage' => json_encode($folderUsage),
|
||||||
|
'recorded_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$recorded++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->error("Failed to record usage for tenant {$tenant->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Storage usage recorded for {$recorded}/{$tenants->count()} tenants");
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ public static function handle(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
$code = (int) ($result['code'] ?? 400);
|
$code = (int) ($result['code'] ?? 400);
|
||||||
$message = (string) ($result['message'] ?? ($result['error'] ?? ($responseTitle.' 실패')));
|
$message = (string) ($result['message'] ?? ($result['error'] ?? '서버 에러'));
|
||||||
$details = $result['details'] ?? null;
|
$details = $result['details'] ?? null;
|
||||||
|
|
||||||
// 에러에도 쿼리 로그 포함되도록 error()가 처리하게 맡김
|
// 에러에도 쿼리 로그 포함되도록 error()가 처리하게 맡김
|
||||||
@@ -161,20 +161,20 @@ public static function handle(
|
|||||||
: [];
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::success($data, $responseTitle.' 성공', $debug);
|
return self::success($data, $responseTitle, $debug);
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|
||||||
// HttpException 계열은 상태코드/메시지를 그대로 반영
|
// HttpException 계열은 상태코드/메시지를 그대로 반영
|
||||||
if ($e instanceof HttpException) {
|
if ($e instanceof HttpException) {
|
||||||
return self::error(
|
return self::error(
|
||||||
$e->getMessage() ?: ($responseTitle.' 실패'),
|
$e->getMessage() ?: '서버 에러',
|
||||||
$e->getStatusCode(),
|
$e->getStatusCode(),
|
||||||
['details' => config('app.debug') ? $e->getTraceAsString() : null]
|
['details' => config('app.debug') ? $e->getTraceAsString() : null]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return self::error($responseTitle.' 실패', 500, [
|
return self::error('서버 에러', 500, [
|
||||||
'details' => $e->getMessage(),
|
'details' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,4 +111,4 @@ private static function toBase36(int $num): string
|
|||||||
{
|
{
|
||||||
return str_pad(strtoupper(base_convert((string) $num, 10, 36)), 4, '0', STR_PAD_LEFT);
|
return str_pad(strtoupper(base_convert((string) $num, 10, 36)), 4, '0', STR_PAD_LEFT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,4 +59,4 @@ public function tree(Request $request)
|
|||||||
{
|
{
|
||||||
return ApiResponse::handle(fn () => $this->service->tree($request->all()), __('message.category.tree_fetched'));
|
return ApiResponse::handle(fn () => $this->service->tree($request->all()), __('message.category.tree_fetched'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public function destroy(int $id)
|
|||||||
{
|
{
|
||||||
return ApiResponse::handle(function () use ($id) {
|
return ApiResponse::handle(function () use ($id) {
|
||||||
$this->service->destroy($id);
|
$this->service->destroy($id);
|
||||||
|
|
||||||
return 'success';
|
return 'success';
|
||||||
}, __('message.client.deleted'));
|
}, __('message.client.deleted'));
|
||||||
}
|
}
|
||||||
@@ -55,4 +56,4 @@ public function toggle(int $id)
|
|||||||
return $this->service->toggle($id);
|
return $this->service->toggle($id);
|
||||||
}, __('message.client.toggled'));
|
}, __('message.client.toggled'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,294 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1;
|
|
||||||
|
|
||||||
use App\Helpers\ApiResponse;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Services\FileService;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Tag(
|
|
||||||
* name="Files",
|
|
||||||
* description="파일 관리 API"
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
class FileController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @OA\Post(
|
|
||||||
* path="/api/v1/file/upload",
|
|
||||||
* summary="파일 업로드",
|
|
||||||
* description="파일을 업로드합니다.",
|
|
||||||
* tags={"Files"},
|
|
||||||
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
|
|
||||||
*
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
*
|
|
||||||
* @OA\MediaType(
|
|
||||||
* mediaType="multipart/form-data",
|
|
||||||
*
|
|
||||||
* @OA\Schema(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(
|
|
||||||
* property="files[]",
|
|
||||||
* type="array",
|
|
||||||
*
|
|
||||||
* @OA\Items(type="string", format="binary"),
|
|
||||||
* description="업로드할 파일들"
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=201,
|
|
||||||
* description="파일 업로드 성공",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일 업로드"),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="data",
|
|
||||||
* type="array",
|
|
||||||
*
|
|
||||||
* @OA\Items(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="id", type="integer", example=1),
|
|
||||||
* @OA\Property(property="filename", type="string", example="document.pdf"),
|
|
||||||
* @OA\Property(property="path", type="string", example="/uploads/tenant/1/document.pdf"),
|
|
||||||
* @OA\Property(property="size", type="integer", example=1024)
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=400,
|
|
||||||
* description="파일 업로드 실패",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일 업로드에 실패했습니다.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=413,
|
|
||||||
* description="파일 크기 초과",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일 크기가 너무 큽니다.")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=415,
|
|
||||||
* description="지원하지 않는 파일 형식",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="message", type="string", example="허용되지 않는 파일 형식입니다.")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function upload(Request $request)
|
|
||||||
{
|
|
||||||
return ApiResponse::handle(function () use ($request) {
|
|
||||||
return FileService::uploadFiles($request->all());
|
|
||||||
}, '파일 업로드');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/v1/file/list",
|
|
||||||
* summary="파일 목록 조회",
|
|
||||||
* description="파일 목록을 조회합니다.",
|
|
||||||
* tags={"Files"},
|
|
||||||
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
|
|
||||||
*
|
|
||||||
* @OA\Parameter(
|
|
||||||
* name="page",
|
|
||||||
* in="query",
|
|
||||||
* description="페이지 번호",
|
|
||||||
*
|
|
||||||
* @OA\Schema(type="integer", example=1)
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Parameter(
|
|
||||||
* name="size",
|
|
||||||
* in="query",
|
|
||||||
* description="페이지 크기",
|
|
||||||
*
|
|
||||||
* @OA\Schema(type="integer", example=10)
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="파일 목록 조회 성공",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일 목록조회"),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="data",
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="current_page", type="integer", example=1),
|
|
||||||
* @OA\Property(property="per_page", type="integer", example=10),
|
|
||||||
* @OA\Property(property="total", type="integer", example=25),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="data",
|
|
||||||
* type="array",
|
|
||||||
*
|
|
||||||
* @OA\Items(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="id", type="integer", example=1),
|
|
||||||
* @OA\Property(property="filename", type="string", example="document.pdf"),
|
|
||||||
* @OA\Property(property="path", type="string", example="/uploads/tenant/1/document.pdf"),
|
|
||||||
* @OA\Property(property="size", type="integer", example=1024),
|
|
||||||
* @OA\Property(property="uploaded_at", type="string", format="date-time")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function list(Request $request)
|
|
||||||
{
|
|
||||||
return ApiResponse::handle(function () use ($request) {
|
|
||||||
return FileService::getFiles($request->all());
|
|
||||||
}, '파일 목록조회');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Delete(
|
|
||||||
* path="/api/v1/file/delete",
|
|
||||||
* summary="파일 삭제",
|
|
||||||
* description="파일을 삭제합니다.",
|
|
||||||
* tags={"Files"},
|
|
||||||
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
|
|
||||||
*
|
|
||||||
* @OA\RequestBody(
|
|
||||||
* required=true,
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(
|
|
||||||
* property="file_ids",
|
|
||||||
* type="array",
|
|
||||||
*
|
|
||||||
* @OA\Items(type="integer"),
|
|
||||||
* example={1, 2, 3}
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="파일 삭제 성공",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일 삭제")
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=404,
|
|
||||||
* description="파일을 찾을 수 없음",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일을 찾을 수 없습니다.")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function delete(Request $request)
|
|
||||||
{
|
|
||||||
return ApiResponse::handle(function () use ($request) {
|
|
||||||
return FileService::deleteFiles($request->all());
|
|
||||||
}, '파일 삭제');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @OA\Get(
|
|
||||||
* path="/api/v1/file/find",
|
|
||||||
* summary="파일 정보 조회",
|
|
||||||
* description="특정 파일의 정보를 조회합니다.",
|
|
||||||
* tags={"Files"},
|
|
||||||
* security={{"ApiKeyAuth": {}, "BearerAuth": {}}},
|
|
||||||
*
|
|
||||||
* @OA\Parameter(
|
|
||||||
* name="file_id",
|
|
||||||
* in="query",
|
|
||||||
* required=true,
|
|
||||||
* description="파일 ID",
|
|
||||||
*
|
|
||||||
* @OA\Schema(type="integer", example=1)
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=200,
|
|
||||||
* description="파일 정보 조회 성공",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=true),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일 정보 조회"),
|
|
||||||
* @OA\Property(
|
|
||||||
* property="data",
|
|
||||||
* type="object",
|
|
||||||
* @OA\Property(property="id", type="integer", example=1),
|
|
||||||
* @OA\Property(property="filename", type="string", example="document.pdf"),
|
|
||||||
* @OA\Property(property="path", type="string", example="/uploads/tenant/1/document.pdf"),
|
|
||||||
* @OA\Property(property="size", type="integer", example=1024),
|
|
||||||
* @OA\Property(property="mime_type", type="string", example="application/pdf"),
|
|
||||||
* @OA\Property(property="uploaded_at", type="string", format="date-time")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* ),
|
|
||||||
*
|
|
||||||
* @OA\Response(
|
|
||||||
* response=404,
|
|
||||||
* description="파일을 찾을 수 없음",
|
|
||||||
*
|
|
||||||
* @OA\JsonContent(
|
|
||||||
* type="object",
|
|
||||||
*
|
|
||||||
* @OA\Property(property="success", type="boolean", example=false),
|
|
||||||
* @OA\Property(property="message", type="string", example="파일을 찾을 수 없습니다.")
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
* )
|
|
||||||
*/
|
|
||||||
public function findFile(Request $request)
|
|
||||||
{
|
|
||||||
return ApiResponse::handle(function () use ($request) {
|
|
||||||
return FileService::findFile($request->all());
|
|
||||||
}, '파일 정보 조회');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
174
app/Http/Controllers/Api/V1/FileStorageController.php
Normal file
174
app/Http/Controllers/Api/V1/FileStorageController.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Helpers\ApiResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\FileMoveRequest;
|
||||||
|
use App\Http\Requests\Api\V1\FileUploadRequest;
|
||||||
|
use App\Http\Requests\Api\V1\ShareLinkRequest;
|
||||||
|
use App\Services\FileStorageService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class FileStorageController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Upload file to temp
|
||||||
|
*/
|
||||||
|
public function upload(FileUploadRequest $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
$file = $service->upload(
|
||||||
|
$request->file('file'),
|
||||||
|
$request->input('description')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $file;
|
||||||
|
}, __('message.file_uploaded'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move files from temp to folder
|
||||||
|
*/
|
||||||
|
public function move(FileMoveRequest $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
$files = $service->moveToFolder(
|
||||||
|
$request->input('file_ids'),
|
||||||
|
$request->input('folder_id'),
|
||||||
|
$request->input('document_id'),
|
||||||
|
$request->input('document_type')
|
||||||
|
);
|
||||||
|
|
||||||
|
return $files;
|
||||||
|
}, __('message.files_moved'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file by ID
|
||||||
|
*/
|
||||||
|
public function show(int $id)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
|
||||||
|
return $service->getFile($id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files
|
||||||
|
*/
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
|
||||||
|
return $service->listFiles($request->all());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trash files
|
||||||
|
*/
|
||||||
|
public function trash()
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
|
||||||
|
return $service->getTrash();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file
|
||||||
|
*/
|
||||||
|
public function download(int $id)
|
||||||
|
{
|
||||||
|
$service = new FileStorageService;
|
||||||
|
$file = $service->getFile($id);
|
||||||
|
|
||||||
|
return $file->download();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete file
|
||||||
|
*/
|
||||||
|
public function destroy(int $id)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
|
||||||
|
return $service->deleteFile($id);
|
||||||
|
}, __('message.file_deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore file from trash
|
||||||
|
*/
|
||||||
|
public function restore(int $id)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
|
||||||
|
return $service->restoreFile($id);
|
||||||
|
}, __('message.file_restored'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete file
|
||||||
|
*/
|
||||||
|
public function permanentDelete(int $id)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
$service->permanentDelete($id);
|
||||||
|
|
||||||
|
return ['success' => true];
|
||||||
|
}, __('message.file_permanently_deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create share link
|
||||||
|
*/
|
||||||
|
public function createShareLink(ShareLinkRequest $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
$link = $service->createShareLink(
|
||||||
|
$request->input('file_id'),
|
||||||
|
$request->input('expiry_hours', 24)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'token' => $link->token,
|
||||||
|
'url' => url("/api/v1/files/share/{$link->token}"),
|
||||||
|
'expires_at' => $link->expires_at,
|
||||||
|
];
|
||||||
|
}, __('message.share_link_created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download file by share token (public, no auth)
|
||||||
|
*/
|
||||||
|
public function downloadShared(string $token)
|
||||||
|
{
|
||||||
|
$file = FileStorageService::getFileByShareToken($token);
|
||||||
|
|
||||||
|
return $file->download();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage usage
|
||||||
|
*/
|
||||||
|
public function storageUsage()
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () {
|
||||||
|
$service = new FileStorageService;
|
||||||
|
|
||||||
|
return $service->getStorageUsage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Http/Controllers/Api/V1/FolderController.php
Normal file
88
app/Http/Controllers/Api/V1/FolderController.php
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\V1;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Api\V1\FolderStoreRequest;
|
||||||
|
use App\Http\Requests\Api\V1\FolderUpdateRequest;
|
||||||
|
use App\Services\FolderService;
|
||||||
|
use App\Utils\ApiResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class FolderController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List all folders
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () {
|
||||||
|
$service = new FolderService;
|
||||||
|
|
||||||
|
return $service->index();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new folder
|
||||||
|
*/
|
||||||
|
public function store(FolderStoreRequest $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
$service = new FolderService;
|
||||||
|
$folder = $service->store($request->validated());
|
||||||
|
|
||||||
|
return $folder;
|
||||||
|
}, __('message.folder_created'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get folder by ID
|
||||||
|
*/
|
||||||
|
public function show(int $id)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
$service = new FolderService;
|
||||||
|
|
||||||
|
return $service->show($id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update folder
|
||||||
|
*/
|
||||||
|
public function update(int $id, FolderUpdateRequest $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id, $request) {
|
||||||
|
$service = new FolderService;
|
||||||
|
$folder = $service->update($id, $request->validated());
|
||||||
|
|
||||||
|
return $folder;
|
||||||
|
}, __('message.folder_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete/deactivate folder
|
||||||
|
*/
|
||||||
|
public function destroy(int $id)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($id) {
|
||||||
|
$service = new FolderService;
|
||||||
|
|
||||||
|
return $service->destroy($id);
|
||||||
|
}, __('message.folder_deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder folders
|
||||||
|
*/
|
||||||
|
public function reorder(Request $request)
|
||||||
|
{
|
||||||
|
return ApiResponse::handle(function () use ($request) {
|
||||||
|
$service = new FolderService;
|
||||||
|
$orders = $service->reorder($request->input('orders', []));
|
||||||
|
|
||||||
|
return $orders;
|
||||||
|
}, __('message.folders_reordered'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,4 +47,4 @@ public function destroy(int $id)
|
|||||||
return $this->service->destroyMaterial($id);
|
return $this->service->destroyMaterial($id);
|
||||||
}, __('message.material.deleted'));
|
}, __('message.material.deleted'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ class RefreshController extends Controller
|
|||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* 리프레시 토큰으로 새로운 액세스 토큰을 발급합니다.
|
* 리프레시 토큰으로 새로운 액세스 토큰을 발급합니다.
|
||||||
*
|
|
||||||
* @param RefreshRequest $request
|
|
||||||
* @return JsonResponse
|
|
||||||
*/
|
*/
|
||||||
public function refresh(RefreshRequest $request): JsonResponse
|
public function refresh(RefreshRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -38,4 +35,4 @@ public function refresh(RefreshRequest $request): JsonResponse
|
|||||||
'expires_at' => $tokens['expires_at'],
|
'expires_at' => $tokens['expires_at'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,4 +54,4 @@ public function restore(Request $request)
|
|||||||
return $this->service->restoreTenant($request->all());
|
return $this->service->restoreTenant($request->all());
|
||||||
}, __('message.tenant.restored'));
|
}, __('message.tenant.restored'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,4 @@ public function switchTenant(SwitchTenantRequest $request)
|
|||||||
return $this->service->switchMyTenant($request->validated()['tenant_id']);
|
return $this->service->switchMyTenant($request->validated()['tenant_id']);
|
||||||
}, __('message.user.tenant_switched'));
|
}, __('message.user.tenant_switched'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
app/Http/Requests/Api/V1/FileMoveRequest.php
Normal file
35
app/Http/Requests/Api/V1/FileMoveRequest.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class FileMoveRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file_ids' => 'required|array|min:1',
|
||||||
|
'file_ids.*' => 'required|integer|exists:files,id',
|
||||||
|
'folder_id' => 'required|integer|exists:folders,id',
|
||||||
|
'document_id' => 'nullable|integer',
|
||||||
|
'document_type' => 'nullable|string|max:100',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file_ids.required' => __('error.file_ids_required'),
|
||||||
|
'file_ids.array' => __('error.file_ids_must_be_array'),
|
||||||
|
'file_ids.*.exists' => __('error.file_not_found'),
|
||||||
|
'folder_id.required' => __('error.folder_id_required'),
|
||||||
|
'folder_id.exists' => __('error.folder_not_found'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Http/Requests/Api/V1/FileUploadRequest.php
Normal file
39
app/Http/Requests/Api/V1/FileUploadRequest.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class FileUploadRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$maxFileSize = config('filesystems.file_constraints.max_file_size', 20971520); // 20MB default
|
||||||
|
$allowedExtensions = implode(',', config('filesystems.file_constraints.allowed_extensions', []));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'file' => [
|
||||||
|
'required',
|
||||||
|
'file',
|
||||||
|
'max:'.($maxFileSize / 1024), // KB
|
||||||
|
'mimes:'.$allowedExtensions,
|
||||||
|
],
|
||||||
|
'description' => 'nullable|string|max:500',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file.required' => __('error.file_required'),
|
||||||
|
'file.file' => __('error.file_invalid'),
|
||||||
|
'file.max' => __('error.file_too_large'),
|
||||||
|
'file.mimes' => __('error.file_type_not_allowed'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Http/Requests/Api/V1/FolderStoreRequest.php
Normal file
46
app/Http/Requests/Api/V1/FolderStoreRequest.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class FolderStoreRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'folder_key' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:50',
|
||||||
|
'regex:/^[a-z0-9_-]+$/',
|
||||||
|
Rule::unique('folders')->where(function ($query) {
|
||||||
|
return $query->where('tenant_id', auth()->user()->tenant_id ?? 0);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'folder_name' => 'required|string|max:100',
|
||||||
|
'description' => 'nullable|string|max:500',
|
||||||
|
'display_order' => 'nullable|integer|min:0',
|
||||||
|
'is_active' => 'nullable|boolean',
|
||||||
|
'icon' => 'nullable|string|max:50',
|
||||||
|
'color' => 'nullable|string|max:20|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'folder_key.required' => __('error.folder_key_required'),
|
||||||
|
'folder_key.unique' => __('error.folder_key_duplicate'),
|
||||||
|
'folder_key.regex' => __('error.folder_key_format'),
|
||||||
|
'folder_name.required' => __('error.folder_name_required'),
|
||||||
|
'color.regex' => __('error.color_format'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Http/Requests/Api/V1/FolderUpdateRequest.php
Normal file
47
app/Http/Requests/Api/V1/FolderUpdateRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class FolderUpdateRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$folderId = $this->route('id');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'folder_key' => [
|
||||||
|
'sometimes',
|
||||||
|
'string',
|
||||||
|
'max:50',
|
||||||
|
'regex:/^[a-z0-9_-]+$/',
|
||||||
|
Rule::unique('folders')->where(function ($query) use ($folderId) {
|
||||||
|
return $query->where('tenant_id', auth()->user()->tenant_id ?? 0)
|
||||||
|
->where('id', '!=', $folderId);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'folder_name' => 'sometimes|string|max:100',
|
||||||
|
'description' => 'nullable|string|max:500',
|
||||||
|
'display_order' => 'sometimes|integer|min:0',
|
||||||
|
'is_active' => 'sometimes|boolean',
|
||||||
|
'icon' => 'nullable|string|max:50',
|
||||||
|
'color' => 'nullable|string|max:20|regex:/^#[0-9A-Fa-f]{6}$/',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'folder_key.unique' => __('error.folder_key_duplicate'),
|
||||||
|
'folder_key.regex' => __('error.folder_key_format'),
|
||||||
|
'color.regex' => __('error.color_format'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,4 +34,4 @@ public function messages(): array
|
|||||||
'refresh_token.string' => __('error.refresh_token_invalid'),
|
'refresh_token.string' => __('error.refresh_token_invalid'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Http/Requests/Api/V1/ShareLinkRequest.php
Normal file
41
app/Http/Requests/Api/V1/ShareLinkRequest.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Api\V1;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class ShareLinkRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATH 파라미터 {id}를 file_id로 자동 병합
|
||||||
|
*/
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->merge([
|
||||||
|
'file_id' => $this->route('id'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file_id' => 'required|integer|exists:files,id',
|
||||||
|
'expiry_hours' => 'nullable|integer|min:1|max:168', // Max 7 days
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file_id.required' => __('error.file_id_required'),
|
||||||
|
'file_id.exists' => __('error.file_not_found'),
|
||||||
|
'expiry_hours.min' => __('error.expiry_hours_min'),
|
||||||
|
'expiry_hours.max' => __('error.expiry_hours_max'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,4 +18,4 @@ public function rules(): array
|
|||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ public function rules(): array
|
|||||||
'items.*.sort_order' => 'required|integer',
|
'items.*.sort_order' => 'required|integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ public function rules(): array
|
|||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ public function rules(): array
|
|||||||
'sort_order' => 'nullable|integer',
|
'sort_order' => 'nullable|integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ public function rules(): array
|
|||||||
'is_active' => 'nullable|in:Y,N',
|
'is_active' => 'nullable|in:Y,N',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ public function rules(): array
|
|||||||
'is_active' => 'nullable|in:Y,N',
|
'is_active' => 'nullable|in:Y,N',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ public function rules(): array
|
|||||||
'specification' => 'nullable|string|max:255',
|
'specification' => 'nullable|string|max:255',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ public function rules(): array
|
|||||||
'specification' => 'nullable|string|max:255',
|
'specification' => 'nullable|string|max:255',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ public function rules(): array
|
|||||||
'is_active' => 'nullable|in:0,1',
|
'is_active' => 'nullable|in:0,1',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,4 +26,4 @@ public function rules(): array
|
|||||||
'is_active' => 'nullable|in:0,1',
|
'is_active' => 'nullable|in:0,1',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ public function rules(): array
|
|||||||
'ceo_name' => 'nullable|string|max:100',
|
'ceo_name' => 'nullable|string|max:100',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,4 +23,4 @@ public function rules(): array
|
|||||||
'ceo_name' => 'nullable|string|max:100',
|
'ceo_name' => 'nullable|string|max:100',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ public function rules(): array
|
|||||||
'new_password_confirmation' => 'required|string',
|
'new_password_confirmation' => 'required|string',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ public function rules(): array
|
|||||||
'tenant_id' => 'required|integer|exists:tenants,id',
|
'tenant_id' => 'required|integer|exists:tenants,id',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ public function rules(): array
|
|||||||
'email' => 'sometimes|email|max:100',
|
'email' => 'sometimes|email|max:100',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,23 @@
|
|||||||
|
|
||||||
namespace App\Models\Commons;
|
namespace App\Models\Commons;
|
||||||
|
|
||||||
|
use App\Models\FileShareLink;
|
||||||
|
use App\Models\Folder;
|
||||||
use App\Models\Members\User;
|
use App\Models\Members\User;
|
||||||
|
use App\Models\Tenants\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin IdeHelperFile
|
* @mixin IdeHelperFile
|
||||||
*/
|
*/
|
||||||
class File extends Model
|
class File extends Model
|
||||||
{
|
{
|
||||||
|
use \App\Traits\BelongsToTenant;
|
||||||
|
use \App\Traits\ModelTrait;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $table = 'files';
|
protected $table = 'files';
|
||||||
@@ -18,19 +26,71 @@ class File extends Model
|
|||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'tenant_id',
|
'tenant_id',
|
||||||
'file_path',
|
'file_path',
|
||||||
|
// Old fields (legacy support)
|
||||||
'original_name',
|
'original_name',
|
||||||
'file_name',
|
'file_name',
|
||||||
'file_name_old',
|
'file_name_old',
|
||||||
|
'fileable_id',
|
||||||
|
'fileable_type',
|
||||||
|
// New fields
|
||||||
|
'display_name',
|
||||||
|
'stored_name',
|
||||||
|
'folder_id',
|
||||||
|
'is_temp',
|
||||||
|
'file_type',
|
||||||
|
'document_id',
|
||||||
|
'document_type',
|
||||||
'file_size',
|
'file_size',
|
||||||
'mime_type',
|
'mime_type',
|
||||||
'description',
|
'description',
|
||||||
'fileable_id',
|
|
||||||
'fileable_type',
|
|
||||||
'uploaded_by',
|
'uploaded_by',
|
||||||
|
'deleted_by',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_temp' => 'boolean',
|
||||||
|
'file_size' => 'integer',
|
||||||
|
'deleted_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 연관된 모델 (Polymorphic)
|
* Get the tenant that owns the file
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the folder that contains this file
|
||||||
|
*/
|
||||||
|
public function folder(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Folder::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all share links for this file
|
||||||
|
*/
|
||||||
|
public function shareLinks(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(FileShareLink::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the uploader (User)
|
||||||
|
*/
|
||||||
|
public function uploader(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'uploaded_by');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy: 연관된 모델 (Polymorphic) - for backward compatibility
|
||||||
|
*
|
||||||
|
* @deprecated Use document_id and document_type instead
|
||||||
*/
|
*/
|
||||||
public function fileable()
|
public function fileable()
|
||||||
{
|
{
|
||||||
@@ -38,10 +98,134 @@ public function fileable()
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 업로더 (User 등)
|
* Get the full storage path
|
||||||
*/
|
*/
|
||||||
public function uploader()
|
public function getStoragePath(): string
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'uploaded_by');
|
return Storage::disk('tenant')->path($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file exists in storage
|
||||||
|
*/
|
||||||
|
public function exists(): bool
|
||||||
|
{
|
||||||
|
return Storage::disk('tenant')->exists($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get download response
|
||||||
|
*/
|
||||||
|
public function download()
|
||||||
|
{
|
||||||
|
if (! $this->exists()) {
|
||||||
|
abort(404, 'File not found in storage');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->download(
|
||||||
|
$this->getStoragePath(),
|
||||||
|
$this->display_name ?? $this->original_name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move file from temp to folder
|
||||||
|
*/
|
||||||
|
public function moveToFolder(Folder $folder): bool
|
||||||
|
{
|
||||||
|
if (! $this->is_temp) {
|
||||||
|
return false; // Already moved
|
||||||
|
}
|
||||||
|
|
||||||
|
// New path: /tenants/{tenant_id}/{folder_key}/{year}/{month}/{stored_name}
|
||||||
|
$date = now();
|
||||||
|
$newPath = sprintf(
|
||||||
|
'%d/%s/%s/%s/%s',
|
||||||
|
$this->tenant_id,
|
||||||
|
$folder->folder_key,
|
||||||
|
$date->format('Y'),
|
||||||
|
$date->format('m'),
|
||||||
|
$this->stored_name ?? $this->file_name
|
||||||
|
);
|
||||||
|
|
||||||
|
// Move physical file
|
||||||
|
if (Storage::disk('tenant')->exists($this->file_path)) {
|
||||||
|
Storage::disk('tenant')->move($this->file_path, $newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DB
|
||||||
|
$this->update([
|
||||||
|
'file_path' => $newPath,
|
||||||
|
'folder_id' => $folder->id,
|
||||||
|
'is_temp' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete file (soft delete)
|
||||||
|
*/
|
||||||
|
public function softDeleteFile(int $userId): void
|
||||||
|
{
|
||||||
|
// Set deleted_by before soft delete
|
||||||
|
$this->deleted_by = $userId;
|
||||||
|
$this->save();
|
||||||
|
|
||||||
|
// Use SoftDeletes trait's delete() method
|
||||||
|
$this->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete file
|
||||||
|
*/
|
||||||
|
public function permanentDelete(): void
|
||||||
|
{
|
||||||
|
// Delete physical file
|
||||||
|
if ($this->exists()) {
|
||||||
|
Storage::disk('tenant')->delete($this->file_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrement tenant storage
|
||||||
|
$tenant = Tenant::find($this->tenant_id);
|
||||||
|
if ($tenant) {
|
||||||
|
$tenant->decrement('storage_used', $this->file_size);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force delete from DB
|
||||||
|
$this->forceDelete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Temp files only
|
||||||
|
*/
|
||||||
|
public function scopeTemp($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_temp', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Non-temp files only
|
||||||
|
*/
|
||||||
|
public function scopeNonTemp($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_temp', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: By folder
|
||||||
|
*/
|
||||||
|
public function scopeInFolder($query, $folderId)
|
||||||
|
{
|
||||||
|
return $query->where('folder_id', $folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: By document
|
||||||
|
*/
|
||||||
|
public function scopeForDocument($query, int $documentId, string $documentType)
|
||||||
|
{
|
||||||
|
return $query->where('document_id', $documentId)
|
||||||
|
->where('document_type', $documentType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
122
app/Models/FileShareLink.php
Normal file
122
app/Models/FileShareLink.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Models\Commons\File;
|
||||||
|
use App\Models\Tenants\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class FileShareLink extends Model
|
||||||
|
{
|
||||||
|
use \App\Traits\BelongsToTenant;
|
||||||
|
|
||||||
|
public $timestamps = false; // created_at만 사용
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'file_id',
|
||||||
|
'tenant_id',
|
||||||
|
'token',
|
||||||
|
'expires_at',
|
||||||
|
'download_count',
|
||||||
|
'max_downloads',
|
||||||
|
'last_downloaded_at',
|
||||||
|
'last_downloaded_ip',
|
||||||
|
'created_by',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'last_downloaded_at' => 'datetime',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
'download_count' => 'integer',
|
||||||
|
'max_downloads' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot method: Auto-generate token
|
||||||
|
*/
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($model) {
|
||||||
|
if (empty($model->token)) {
|
||||||
|
$model->token = self::generateToken();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique 64-character token
|
||||||
|
*/
|
||||||
|
public static function generateToken(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(32)); // 64 chars
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file associated with this share link
|
||||||
|
*/
|
||||||
|
public function file(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(File::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tenant that owns the share link
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the link is expired
|
||||||
|
*/
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at && $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if download limit reached
|
||||||
|
*/
|
||||||
|
public function isDownloadLimitReached(): bool
|
||||||
|
{
|
||||||
|
return $this->max_downloads && $this->download_count >= $this->max_downloads;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the link is still valid
|
||||||
|
*/
|
||||||
|
public function isValid(): bool
|
||||||
|
{
|
||||||
|
return ! $this->isExpired() && ! $this->isDownloadLimitReached();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment download count
|
||||||
|
*/
|
||||||
|
public function incrementDownloadCount(string $ip): void
|
||||||
|
{
|
||||||
|
$this->increment('download_count');
|
||||||
|
$this->update([
|
||||||
|
'last_downloaded_at' => now(),
|
||||||
|
'last_downloaded_ip' => $ip,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Non-expired links
|
||||||
|
*/
|
||||||
|
public function scopeValid($query)
|
||||||
|
{
|
||||||
|
return $query->where('expires_at', '>', now())
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('max_downloads')
|
||||||
|
->orWhereRaw('download_count < max_downloads');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Models/Folder.php
Normal file
63
app/Models/Folder.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Folder extends Model
|
||||||
|
{
|
||||||
|
use \App\Traits\BelongsToTenant;
|
||||||
|
use \App\Traits\ModelTrait;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id',
|
||||||
|
'folder_key',
|
||||||
|
'folder_name',
|
||||||
|
'description',
|
||||||
|
'display_order',
|
||||||
|
'is_active',
|
||||||
|
'icon',
|
||||||
|
'color',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'display_order' => 'integer',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tenant that owns the folder
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all files in this folder
|
||||||
|
*/
|
||||||
|
public function files(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(File::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Active folders only
|
||||||
|
*/
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: Ordered by display_order
|
||||||
|
*/
|
||||||
|
public function scopeOrdered($query)
|
||||||
|
{
|
||||||
|
return $query->orderBy('display_order');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,4 +119,129 @@ public function files()
|
|||||||
{
|
{
|
||||||
return $this->morphMany(File::class, 'fileable');
|
return $this->morphMany(File::class, 'fileable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage usage percentage
|
||||||
|
*/
|
||||||
|
public function getStorageUsagePercentage(): float
|
||||||
|
{
|
||||||
|
if (! $this->storage_limit || $this->storage_limit == 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($this->storage_used / $this->storage_limit) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if storage is near limit (90%)
|
||||||
|
*/
|
||||||
|
public function isStorageNearLimit(): bool
|
||||||
|
{
|
||||||
|
return $this->getStorageUsagePercentage() >= 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if storage quota exceeded
|
||||||
|
*/
|
||||||
|
public function isStorageExceeded(): bool
|
||||||
|
{
|
||||||
|
return $this->storage_used > $this->storage_limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if in grace period
|
||||||
|
*/
|
||||||
|
public function isInGracePeriod(): bool
|
||||||
|
{
|
||||||
|
return $this->storage_grace_period_until && now()->lessThan($this->storage_grace_period_until);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if upload is allowed
|
||||||
|
*/
|
||||||
|
public function canUpload(int $fileSize = 0): array
|
||||||
|
{
|
||||||
|
$newUsage = $this->storage_used + $fileSize;
|
||||||
|
|
||||||
|
// Check if exceeds limit
|
||||||
|
if ($newUsage > $this->storage_limit) {
|
||||||
|
// Check grace period
|
||||||
|
if ($this->isInGracePeriod()) {
|
||||||
|
return [
|
||||||
|
'allowed' => true,
|
||||||
|
'warning' => true,
|
||||||
|
'message' => __('file.storage_exceeded_grace_period', [
|
||||||
|
'until' => $this->storage_grace_period_until->format('Y-m-d'),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grace period expired - block upload
|
||||||
|
return [
|
||||||
|
'allowed' => false,
|
||||||
|
'message' => __('file.storage_quota_exceeded'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if near limit (90%)
|
||||||
|
$percentage = ($newUsage / $this->storage_limit) * 100;
|
||||||
|
if ($percentage >= 90 && ! $this->storage_warning_sent_at) {
|
||||||
|
// Send warning (once)
|
||||||
|
$this->update([
|
||||||
|
'storage_warning_sent_at' => now(),
|
||||||
|
'storage_grace_period_until' => now()->addDays(7),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// TODO: Dispatch email notification
|
||||||
|
// dispatch(new SendStorageWarningEmail($this));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['allowed' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment storage usage
|
||||||
|
*/
|
||||||
|
public function incrementStorage(int $bytes): void
|
||||||
|
{
|
||||||
|
$this->increment('storage_used', $bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrement storage usage
|
||||||
|
*/
|
||||||
|
public function decrementStorage(int $bytes): void
|
||||||
|
{
|
||||||
|
$this->decrement('storage_used', max(0, $bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable storage used
|
||||||
|
*/
|
||||||
|
public function getStorageUsedFormatted(): string
|
||||||
|
{
|
||||||
|
return $this->formatBytes($this->storage_used);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable storage limit
|
||||||
|
*/
|
||||||
|
public function getStorageLimitFormatted(): string
|
||||||
|
{
|
||||||
|
return $this->formatBytes($this->storage_limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable string
|
||||||
|
*/
|
||||||
|
private function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$bytes = max($bytes, 0);
|
||||||
|
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||||
|
$pow = min($pow, count($units) - 1);
|
||||||
|
$bytes /= (1 << (10 * $pow));
|
||||||
|
|
||||||
|
return round($bytes, 2).' '.$units[$pow];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,82 @@
|
|||||||
|
|
||||||
namespace App\Observers;
|
namespace App\Observers;
|
||||||
|
|
||||||
|
use App\Models\Folder;
|
||||||
use App\Models\Tenants\Tenant;
|
use App\Models\Tenants\Tenant;
|
||||||
use App\Services\TenantBootstrapper;
|
use App\Services\TenantBootstrapper;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class TenantObserver
|
class TenantObserver
|
||||||
{
|
{
|
||||||
public function created(Tenant $tenant): void
|
public function created(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
|
// 테넌트 부트스트랩 실행
|
||||||
app(TenantBootstrapper::class)->bootstrap((int) $tenant->id, 'STANDARD');
|
app(TenantBootstrapper::class)->bootstrap((int) $tenant->id, 'STANDARD');
|
||||||
|
|
||||||
|
// 기본 폴더 생성
|
||||||
|
try {
|
||||||
|
$defaultFolders = [
|
||||||
|
[
|
||||||
|
'folder_key' => 'product',
|
||||||
|
'folder_name' => '생산관리',
|
||||||
|
'description' => '생산 관련 문서 및 도면',
|
||||||
|
'icon' => 'icon-production',
|
||||||
|
'color' => '#3B82F6',
|
||||||
|
'display_order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'quality',
|
||||||
|
'folder_name' => '품질관리',
|
||||||
|
'description' => '품질 검사 및 인증 문서',
|
||||||
|
'icon' => 'icon-quality',
|
||||||
|
'color' => '#10B981',
|
||||||
|
'display_order' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'accounting',
|
||||||
|
'folder_name' => '회계',
|
||||||
|
'description' => '회계 관련 증빙 서류',
|
||||||
|
'icon' => 'icon-accounting',
|
||||||
|
'color' => '#F59E0B',
|
||||||
|
'display_order' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'hr',
|
||||||
|
'folder_name' => '인사',
|
||||||
|
'description' => '인사 관련 문서',
|
||||||
|
'icon' => 'icon-hr',
|
||||||
|
'color' => '#8B5CF6',
|
||||||
|
'display_order' => 4,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'general',
|
||||||
|
'folder_name' => '일반',
|
||||||
|
'description' => '기타 문서',
|
||||||
|
'icon' => 'icon-general',
|
||||||
|
'color' => '#6B7280',
|
||||||
|
'display_order' => 5,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($defaultFolders as $folder) {
|
||||||
|
Folder::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'folder_key' => $folder['folder_key'],
|
||||||
|
'folder_name' => $folder['folder_name'],
|
||||||
|
'description' => $folder['description'],
|
||||||
|
'icon' => $folder['icon'],
|
||||||
|
'color' => $folder['color'],
|
||||||
|
'display_order' => $folder['display_order'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('Created default folders for new tenant', ['tenant_id' => $tenant->id]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to create default folders for tenant', [
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\Members\User;
|
use App\Models\Members\User;
|
||||||
use Carbon\Carbon;
|
|
||||||
use Illuminate\Support\Facades\Config;
|
use Illuminate\Support\Facades\Config;
|
||||||
|
|
||||||
class AuthService
|
class AuthService
|
||||||
@@ -98,7 +97,6 @@ public static function refreshTokens(string $refreshToken): ?array
|
|||||||
* 사용자의 모든 토큰을 삭제합니다 (로그아웃).
|
* 사용자의 모든 토큰을 삭제합니다 (로그아웃).
|
||||||
*
|
*
|
||||||
* @param User $user 사용자 모델
|
* @param User $user 사용자 모델
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public static function revokeAllTokens(User $user): void
|
public static function revokeAllTokens(User $user): void
|
||||||
{
|
{
|
||||||
@@ -110,10 +108,9 @@ public static function revokeAllTokens(User $user): void
|
|||||||
*
|
*
|
||||||
* @param User $user 사용자 모델
|
* @param User $user 사용자 모델
|
||||||
* @param string $tokenId 토큰 ID
|
* @param string $tokenId 토큰 ID
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public static function revokeToken(User $user, string $tokenId): void
|
public static function revokeToken(User $user, string $tokenId): void
|
||||||
{
|
{
|
||||||
$user->tokens()->where('id', $tokenId)->delete();
|
$user->tokens()->where('id', $tokenId)->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
351
app/Services/FileStorageService.php
Normal file
351
app/Services/FileStorageService.php
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Commons\File;
|
||||||
|
use App\Models\FileShareLink;
|
||||||
|
use App\Models\Folder;
|
||||||
|
use App\Models\Tenants\Tenant;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* New file storage system service (Phase 2 implementation)
|
||||||
|
* Uses: files table, folders table, Storage Facade, tenant disk
|
||||||
|
*/
|
||||||
|
class FileStorageService extends Service
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate 64-bit random filename
|
||||||
|
*/
|
||||||
|
public static function generateStoredName(string $extension): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(32)).'.'.$extension;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload file to temp folder
|
||||||
|
*/
|
||||||
|
public function upload(UploadedFile $file, ?string $description = null): File
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
// Check storage quota
|
||||||
|
$tenant = Tenant::findOrFail($tenantId);
|
||||||
|
$quotaCheck = $tenant->canUpload($file->getSize());
|
||||||
|
|
||||||
|
if (! $quotaCheck['allowed']) {
|
||||||
|
throw new \Exception($quotaCheck['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate stored name
|
||||||
|
$extension = $file->getClientOriginalExtension();
|
||||||
|
$storedName = self::generateStoredName($extension);
|
||||||
|
|
||||||
|
// Build temp path: /tenants/{tenant_id}/temp/{year}/{month}/{stored_name}
|
||||||
|
$date = now();
|
||||||
|
$tempPath = sprintf(
|
||||||
|
'%d/temp/%s/%s/%s',
|
||||||
|
$tenantId,
|
||||||
|
$date->format('Y'),
|
||||||
|
$date->format('m'),
|
||||||
|
$storedName
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store file
|
||||||
|
Storage::disk('tenant')->putFileAs(
|
||||||
|
dirname($tempPath),
|
||||||
|
$file,
|
||||||
|
basename($tempPath)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine file type
|
||||||
|
$mimeType = $file->getMimeType();
|
||||||
|
$fileType = $this->determineFileType($mimeType);
|
||||||
|
|
||||||
|
// Create DB record
|
||||||
|
$fileRecord = File::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'display_name' => $file->getClientOriginalName(),
|
||||||
|
'stored_name' => $storedName,
|
||||||
|
'file_path' => $tempPath,
|
||||||
|
'file_size' => $file->getSize(),
|
||||||
|
'mime_type' => $mimeType,
|
||||||
|
'file_type' => $fileType,
|
||||||
|
'is_temp' => true,
|
||||||
|
'folder_id' => null,
|
||||||
|
'description' => $description,
|
||||||
|
'uploaded_by' => $userId,
|
||||||
|
'created_by' => $userId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Increment tenant storage
|
||||||
|
$tenant->incrementStorage($file->getSize());
|
||||||
|
|
||||||
|
return $fileRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move files from temp to folder
|
||||||
|
*/
|
||||||
|
public function moveToFolder(array $fileIds, int $folderId, ?int $documentId = null, ?string $documentType = null): array
|
||||||
|
{
|
||||||
|
$tenantId = $this->tenantId();
|
||||||
|
$folder = Folder::where('tenant_id', $tenantId)->findOrFail($folderId);
|
||||||
|
|
||||||
|
$movedFiles = [];
|
||||||
|
|
||||||
|
DB::transaction(function () use ($fileIds, $folder, $documentId, $documentType, &$movedFiles) {
|
||||||
|
foreach ($fileIds as $fileId) {
|
||||||
|
$file = File::where('tenant_id', $this->tenantId())
|
||||||
|
->where('is_temp', true)
|
||||||
|
->findOrFail($fileId);
|
||||||
|
|
||||||
|
// Move file
|
||||||
|
$file->moveToFolder($folder);
|
||||||
|
|
||||||
|
// Update document reference
|
||||||
|
if ($documentId && $documentType) {
|
||||||
|
$file->update([
|
||||||
|
'document_id' => $documentId,
|
||||||
|
'document_type' => $documentType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$movedFiles[] = $file->fresh();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $movedFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file by ID
|
||||||
|
*/
|
||||||
|
public function getFile(int $fileId): File
|
||||||
|
{
|
||||||
|
return File::where('tenant_id', $this->tenantId())->findOrFail($fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List files with filters
|
||||||
|
*/
|
||||||
|
public function listFiles(array $filters = []): \Illuminate\Pagination\LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$query = File::where('tenant_id', $this->tenantId())
|
||||||
|
->with(['folder', 'uploader']);
|
||||||
|
|
||||||
|
// Filter by folder
|
||||||
|
if (isset($filters['folder_id'])) {
|
||||||
|
$query->where('folder_id', $filters['folder_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by temp
|
||||||
|
if (isset($filters['is_temp'])) {
|
||||||
|
$query->where('is_temp', $filters['is_temp']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by document
|
||||||
|
if (isset($filters['document_id']) && isset($filters['document_type'])) {
|
||||||
|
$query->forDocument($filters['document_id'], $filters['document_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exclude soft deleted by default
|
||||||
|
if (! isset($filters['with_trashed']) || ! $filters['with_trashed']) {
|
||||||
|
$query->whereNull('deleted_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderBy('created_at', 'desc')
|
||||||
|
->paginate($filters['per_page'] ?? 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trash files
|
||||||
|
*/
|
||||||
|
public function getTrash(): \Illuminate\Pagination\LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return File::where('tenant_id', $this->tenantId())
|
||||||
|
->onlyTrashed() // SoftDeletes: deleted_at IS NOT NULL인 항목만
|
||||||
|
->with(['folder', 'uploader'])
|
||||||
|
->orderBy('deleted_at', 'desc')
|
||||||
|
->paginate(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete file
|
||||||
|
*/
|
||||||
|
public function deleteFile(int $fileId): File
|
||||||
|
{
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
$file = $this->getFile($fileId);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($file, $userId) {
|
||||||
|
// Soft delete
|
||||||
|
$file->softDeleteFile($userId);
|
||||||
|
|
||||||
|
// Log deletion
|
||||||
|
DB::table('file_deletion_logs')->insert([
|
||||||
|
'tenant_id' => $file->tenant_id,
|
||||||
|
'file_id' => $file->id,
|
||||||
|
'file_name' => $file->display_name,
|
||||||
|
'file_path' => $file->file_path,
|
||||||
|
'file_size' => $file->file_size,
|
||||||
|
'folder_id' => $file->folder_id,
|
||||||
|
'document_id' => $file->document_id,
|
||||||
|
'document_type' => $file->document_type,
|
||||||
|
'deleted_by' => $userId,
|
||||||
|
'deleted_at' => now(),
|
||||||
|
'deletion_type' => 'soft',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $file->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore file from trash
|
||||||
|
*/
|
||||||
|
public function restoreFile(int $fileId): File
|
||||||
|
{
|
||||||
|
$file = File::where('tenant_id', $this->tenantId())
|
||||||
|
->onlyTrashed() // SoftDeletes: 삭제된 항목만 조회
|
||||||
|
->findOrFail($fileId);
|
||||||
|
|
||||||
|
$file->restore();
|
||||||
|
$file->update(['deleted_by' => null]);
|
||||||
|
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently delete file
|
||||||
|
*/
|
||||||
|
public function permanentDelete(int $fileId): void
|
||||||
|
{
|
||||||
|
$file = File::where('tenant_id', $this->tenantId())
|
||||||
|
->onlyTrashed() // SoftDeletes: 삭제된 항목만 조회
|
||||||
|
->findOrFail($fileId);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($file) {
|
||||||
|
// Update deletion log
|
||||||
|
DB::table('file_deletion_logs')
|
||||||
|
->where('file_id', $file->id)
|
||||||
|
->where('deletion_type', 'soft')
|
||||||
|
->update(['deletion_type' => 'permanent']);
|
||||||
|
|
||||||
|
// Permanently delete
|
||||||
|
$file->permanentDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create share link
|
||||||
|
*/
|
||||||
|
public function createShareLink(int $fileId, ?int $expiryHours = 24): FileShareLink
|
||||||
|
{
|
||||||
|
$file = $this->getFile($fileId);
|
||||||
|
$userId = $this->apiUserId();
|
||||||
|
|
||||||
|
return FileShareLink::create([
|
||||||
|
'file_id' => $file->id,
|
||||||
|
'tenant_id' => $file->tenant_id,
|
||||||
|
'expires_at' => now()->addHours($expiryHours),
|
||||||
|
'created_by' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get file by share token (no tenant context required)
|
||||||
|
*/
|
||||||
|
public static function getFileByShareToken(string $token): File
|
||||||
|
{
|
||||||
|
$link = FileShareLink::where('token', $token)
|
||||||
|
->valid()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
// Increment download count
|
||||||
|
$link->incrementDownloadCount(request()->ip());
|
||||||
|
|
||||||
|
return $link->file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage usage
|
||||||
|
*/
|
||||||
|
public function getStorageUsage(): array
|
||||||
|
{
|
||||||
|
$tenant = Tenant::findOrFail($this->tenantId());
|
||||||
|
|
||||||
|
// Folder usage
|
||||||
|
$folderUsage = DB::table('files')
|
||||||
|
->where('tenant_id', $this->tenantId())
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereNotNull('folder_id')
|
||||||
|
->selectRaw('folder_id, COUNT(*) as file_count, SUM(file_size) as total_size')
|
||||||
|
->groupBy('folder_id')
|
||||||
|
->get()
|
||||||
|
->map(function ($item) {
|
||||||
|
$folder = Folder::find($item->folder_id);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'folder_id' => $item->folder_id,
|
||||||
|
'folder_name' => $folder->folder_name ?? 'Unknown',
|
||||||
|
'file_count' => $item->file_count,
|
||||||
|
'total_size' => $item->total_size,
|
||||||
|
'formatted_size' => $this->formatBytes($item->total_size),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
'storage_used' => $tenant->storage_used,
|
||||||
|
'storage_limit' => $tenant->storage_limit,
|
||||||
|
'storage_used_formatted' => $tenant->getStorageUsedFormatted(),
|
||||||
|
'storage_limit_formatted' => $tenant->getStorageLimitFormatted(),
|
||||||
|
'usage_percentage' => $tenant->getStorageUsagePercentage(),
|
||||||
|
'is_near_limit' => $tenant->isStorageNearLimit(),
|
||||||
|
'is_exceeded' => $tenant->isStorageExceeded(),
|
||||||
|
'grace_period_until' => $tenant->storage_grace_period_until,
|
||||||
|
'folder_usage' => $folderUsage,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine file type from MIME type
|
||||||
|
*/
|
||||||
|
private function determineFileType(string $mimeType): string
|
||||||
|
{
|
||||||
|
if (str_starts_with($mimeType, 'image/')) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($mimeType, [
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'text/csv',
|
||||||
|
])) {
|
||||||
|
return 'excel';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($mimeType, ['application/zip', 'application/x-rar-compressed'])) {
|
||||||
|
return 'archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'document';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format bytes to human-readable
|
||||||
|
*/
|
||||||
|
private function formatBytes(int $bytes): string
|
||||||
|
{
|
||||||
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
$bytes = max($bytes, 0);
|
||||||
|
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||||
|
$pow = min($pow, count($units) - 1);
|
||||||
|
$bytes /= (1 << (10 * $pow));
|
||||||
|
|
||||||
|
return round($bytes, 2).' '.$units[$pow];
|
||||||
|
}
|
||||||
|
}
|
||||||
109
app/Services/FolderService.php
Normal file
109
app/Services/FolderService.php
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Folder;
|
||||||
|
|
||||||
|
class FolderService extends Service
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all folders for tenant
|
||||||
|
*/
|
||||||
|
public function index(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return Folder::where('tenant_id', $this->tenantId())
|
||||||
|
->ordered()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active folders only
|
||||||
|
*/
|
||||||
|
public function getActiveFolders(): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
return Folder::where('tenant_id', $this->tenantId())
|
||||||
|
->active()
|
||||||
|
->ordered()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get folder by ID
|
||||||
|
*/
|
||||||
|
public function show(int $id): Folder
|
||||||
|
{
|
||||||
|
return Folder::where('tenant_id', $this->tenantId())->findOrFail($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new folder
|
||||||
|
*/
|
||||||
|
public function store(array $data): Folder
|
||||||
|
{
|
||||||
|
$data['tenant_id'] = $this->tenantId();
|
||||||
|
$data['created_by'] = $this->apiUserId();
|
||||||
|
|
||||||
|
// Auto-increment display_order if not provided
|
||||||
|
if (! isset($data['display_order'])) {
|
||||||
|
$maxOrder = Folder::where('tenant_id', $this->tenantId())->max('display_order') ?? 0;
|
||||||
|
$data['display_order'] = $maxOrder + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Folder::create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update folder
|
||||||
|
*/
|
||||||
|
public function update(int $id, array $data): Folder
|
||||||
|
{
|
||||||
|
$folder = $this->show($id);
|
||||||
|
$data['updated_by'] = $this->apiUserId();
|
||||||
|
|
||||||
|
$folder->update($data);
|
||||||
|
|
||||||
|
return $folder->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete folder (set inactive)
|
||||||
|
*/
|
||||||
|
public function destroy(int $id): Folder
|
||||||
|
{
|
||||||
|
$folder = $this->show($id);
|
||||||
|
|
||||||
|
// Check if folder has files
|
||||||
|
if ($folder->files()->count() > 0) {
|
||||||
|
throw new \Exception(__('error.folder_has_files'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$folder->update([
|
||||||
|
'is_active' => false,
|
||||||
|
'updated_by' => $this->apiUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder folders
|
||||||
|
*/
|
||||||
|
public function reorder(array $orders): array
|
||||||
|
{
|
||||||
|
$folders = [];
|
||||||
|
|
||||||
|
foreach ($orders as $order) {
|
||||||
|
$folder = Folder::where('tenant_id', $this->tenantId())
|
||||||
|
->findOrFail($order['id']);
|
||||||
|
|
||||||
|
$folder->update([
|
||||||
|
'display_order' => $order['display_order'],
|
||||||
|
'updated_by' => $this->apiUserId(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$folders[] = $folder->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $folders;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,4 +150,4 @@ public static function createDefaultMenus(int $tenantId): array
|
|||||||
return $menuIds;
|
return $menuIds;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,4 +2,529 @@
|
|||||||
|
|
||||||
namespace App\Swagger\v1;
|
namespace App\Swagger\v1;
|
||||||
|
|
||||||
class FileApi {}
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(name="Files", description="파일 저장소 관리")
|
||||||
|
*
|
||||||
|
* ========= 스키마 정의 =========
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="File",
|
||||||
|
* type="object",
|
||||||
|
* description="파일 모델",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="display_name", type="string", example="계약서.pdf", description="사용자에게 표시되는 파일명"),
|
||||||
|
* @OA\Property(property="stored_name", type="string", example="a1b2c3d4e5f6g7h8.pdf", description="실제 저장된 파일명"),
|
||||||
|
* @OA\Property(property="folder_id", type="integer", nullable=true, example=1),
|
||||||
|
* @OA\Property(property="is_temp", type="boolean", example=false),
|
||||||
|
* @OA\Property(property="file_path", type="string", example="1/product/2025/01/a1b2c3d4e5f6g7h8.pdf"),
|
||||||
|
* @OA\Property(property="file_size", type="integer", example=1024000, description="파일 크기 (bytes)"),
|
||||||
|
* @OA\Property(property="mime_type", type="string", example="application/pdf"),
|
||||||
|
* @OA\Property(property="file_type", type="string", enum={"document","image","excel","archive"}, example="document"),
|
||||||
|
* @OA\Property(property="document_id", type="integer", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="document_type", type="string", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="uploaded_by", type="integer", example=1),
|
||||||
|
* @OA\Property(property="deleted_by", type="integer", nullable=true, example=null),
|
||||||
|
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-01T00:00:00Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-01-01T00:00:00Z"),
|
||||||
|
* @OA\Property(property="deleted_at", type="string", format="date-time", nullable=true, example=null)
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="FileShareLink",
|
||||||
|
* type="object",
|
||||||
|
* description="파일 공유 링크",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="token", type="string", example="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6", description="64자 공유 토큰"),
|
||||||
|
* @OA\Property(property="url", type="string", example="http://api.sam.kr/api/v1/files/share/a1b2c3d4"),
|
||||||
|
* @OA\Property(property="expires_at", type="string", format="date-time", example="2025-01-02T00:00:00Z")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="StorageUsage",
|
||||||
|
* type="object",
|
||||||
|
* description="저장소 사용량 정보",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="storage_limit", type="integer", example=10737418240, description="저장소 한도 (bytes)"),
|
||||||
|
* @OA\Property(property="storage_used", type="integer", example=5368709120, description="사용 중인 용량 (bytes)"),
|
||||||
|
* @OA\Property(property="storage_used_formatted", type="string", example="5.00 GB"),
|
||||||
|
* @OA\Property(property="storage_limit_formatted", type="string", example="10.00 GB"),
|
||||||
|
* @OA\Property(property="usage_percentage", type="number", format="float", example=50.0),
|
||||||
|
* @OA\Property(property="file_count", type="integer", example=150),
|
||||||
|
* @OA\Property(
|
||||||
|
* property="folder_breakdown",
|
||||||
|
* type="object",
|
||||||
|
* description="폴더별 사용량",
|
||||||
|
* example={"product": 2147483648, "quality": 1073741824, "accounting": 536870912}
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class FileApi
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 파일 업로드 (임시 폴더)
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/files/upload",
|
||||||
|
* summary="파일 업로드",
|
||||||
|
* description="파일을 임시 폴더에 업로드합니다. 이후 /files/move로 정식 폴더로 이동시켜야 합니다.",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="multipart/form-data",
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* required={"file"},
|
||||||
|
*
|
||||||
|
* @OA\Property(property="file", type="string", format="binary", description="업로드할 파일 (최대 20MB)"),
|
||||||
|
* @OA\Property(property="description", type="string", nullable=true, maxLength=500, example="계약서 원본")
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 업로드 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/File")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="용량 초과 또는 파일 형식 오류"),
|
||||||
|
* @OA\Response(response=401, description="인증 실패")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function upload() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 이동 (temp → folder)
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/files/move",
|
||||||
|
* summary="파일 이동",
|
||||||
|
* description="임시 폴더의 파일들을 정식 폴더로 이동하고 문서에 첨부합니다.",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* required={"file_ids","folder_id"},
|
||||||
|
*
|
||||||
|
* @OA\Property(property="file_ids", type="array", @OA\Items(type="integer"), example={1,2,3}),
|
||||||
|
* @OA\Property(property="folder_id", type="integer", example=1, description="대상 폴더 ID"),
|
||||||
|
* @OA\Property(property="document_id", type="integer", nullable=true, example=10, description="첨부할 문서 ID"),
|
||||||
|
* @OA\Property(property="document_type", type="string", nullable=true, maxLength=100, example="Order", description="문서 타입")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 이동 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/File"))
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="파일 또는 폴더를 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function move() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 목록 조회
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/files",
|
||||||
|
* summary="파일 목록 조회",
|
||||||
|
* description="폴더별, 문서별 파일 목록을 조회합니다.",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="folder_id",
|
||||||
|
* in="query",
|
||||||
|
* description="폴더 ID (선택)",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="document_id",
|
||||||
|
* in="query",
|
||||||
|
* description="문서 ID (선택)",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="document_type",
|
||||||
|
* in="query",
|
||||||
|
* description="문서 타입 (선택)",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="is_temp",
|
||||||
|
* in="query",
|
||||||
|
* description="임시 파일만 조회",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="boolean")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 목록 조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/File"))
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function index() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 상세 조회
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/files/{id}",
|
||||||
|
* summary="파일 상세 조회",
|
||||||
|
* description="파일 ID로 상세 정보를 조회합니다.",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/File")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="파일을 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function show() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 휴지통 파일 목록
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/files/trash",
|
||||||
|
* summary="휴지통 파일 목록",
|
||||||
|
* description="삭제된 파일 목록을 조회합니다 (30일 보관).",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="휴지통 조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/File"))
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function trash() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 다운로드
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/files/{id}/download",
|
||||||
|
* summary="파일 다운로드",
|
||||||
|
* description="파일을 다운로드합니다.",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 다운로드",
|
||||||
|
*
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="파일을 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function download() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 삭제 (soft delete)
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/v1/files/{id}",
|
||||||
|
* summary="파일 삭제",
|
||||||
|
* description="파일을 휴지통으로 이동합니다 (복구 가능).",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 삭제 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="파일을 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function destroy() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 복구
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/files/{id}/restore",
|
||||||
|
* summary="파일 복구",
|
||||||
|
* description="휴지통의 파일을 복구합니다.",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 복구 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/File")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="파일을 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function restore() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파일 영구 삭제
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/v1/files/{id}/permanent",
|
||||||
|
* summary="파일 영구 삭제",
|
||||||
|
* description="파일을 물리적으로 완전히 삭제합니다 (복구 불가).",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 영구 삭제 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="파일을 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function permanentDelete() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 링크 생성
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/files/{id}/share",
|
||||||
|
* summary="공유 링크 생성",
|
||||||
|
* description="파일의 임시 공유 링크를 생성합니다 (기본 24시간).",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="expiry_hours", type="integer", minimum=1, maximum=168, example=24, description="만료 시간 (시간 단위, 최대 7일)")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="공유 링크 생성 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/FileShareLink")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="파일을 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function createShareLink() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 링크로 다운로드 (인증 불필요)
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/files/share/{token}",
|
||||||
|
* summary="공유 파일 다운로드",
|
||||||
|
* description="공유 토큰으로 파일을 다운로드합니다 (인증 불필요).",
|
||||||
|
* tags={"Files"},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="token",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
* description="64자 공유 토큰",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="string")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="파일 다운로드",
|
||||||
|
*
|
||||||
|
* @OA\MediaType(
|
||||||
|
* mediaType="application/octet-stream",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="string", format="binary")
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="토큰을 찾을 수 없음"),
|
||||||
|
* @OA\Response(response=410, description="링크가 만료됨")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function downloadShared() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장소 사용량 조회
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/storage/usage",
|
||||||
|
* summary="저장소 사용량 조회",
|
||||||
|
* description="현재 테넌트의 저장소 사용량 정보를 조회합니다.",
|
||||||
|
* tags={"Files"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="사용량 조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/StorageUsage")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function storageUsage() {}
|
||||||
|
}
|
||||||
|
|||||||
324
app/Swagger/v1/FolderApi.php
Normal file
324
app/Swagger/v1/FolderApi.php
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Swagger\v1;
|
||||||
|
|
||||||
|
use OpenApi\Annotations as OA;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @OA\Tag(name="Folder", description="폴더 관리")
|
||||||
|
*
|
||||||
|
* ========= 스키마 정의 =========
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="Folder",
|
||||||
|
* type="object",
|
||||||
|
* description="폴더 모델",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="tenant_id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="folder_key", type="string", example="product", description="폴더 키 (영문 소문자, 숫자, 하이픈, 언더스코어만 허용)"),
|
||||||
|
* @OA\Property(property="folder_name", type="string", example="생산관리", description="폴더 표시명"),
|
||||||
|
* @OA\Property(property="description", type="string", nullable=true, example="생산 관련 문서", maxLength=500),
|
||||||
|
* @OA\Property(property="display_order", type="integer", example=1, description="정렬 순서"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="icon", type="string", nullable=true, example="icon-production", maxLength=50),
|
||||||
|
* @OA\Property(property="color", type="string", nullable=true, example="#3B82F6", description="색상 코드 (#RRGGBB 형식)"),
|
||||||
|
* @OA\Property(property="created_by", type="integer", example=1),
|
||||||
|
* @OA\Property(property="updated_by", type="integer", nullable=true, example=1),
|
||||||
|
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-01-01T00:00:00Z"),
|
||||||
|
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-01-01T00:00:00Z")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="FolderStoreRequest",
|
||||||
|
* type="object",
|
||||||
|
* required={"folder_key", "folder_name"},
|
||||||
|
* description="폴더 생성 요청",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="folder_key", type="string", pattern="^[a-z0-9_-]+$", maxLength=50, example="accounting", description="폴더 키 (영문 소문자, 숫자, 하이픈, 언더스코어만)"),
|
||||||
|
* @OA\Property(property="folder_name", type="string", maxLength=100, example="회계", description="폴더 표시명"),
|
||||||
|
* @OA\Property(property="description", type="string", nullable=true, maxLength=500, example="회계 관련 문서"),
|
||||||
|
* @OA\Property(property="display_order", type="integer", nullable=true, minimum=0, example=10, description="정렬 순서 (미지정 시 자동)"),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", nullable=true, example=true),
|
||||||
|
* @OA\Property(property="icon", type="string", nullable=true, maxLength=50, example="icon-accounting"),
|
||||||
|
* @OA\Property(property="color", type="string", nullable=true, pattern="^#[0-9A-Fa-f]{6}$", example="#10B981")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="FolderUpdateRequest",
|
||||||
|
* type="object",
|
||||||
|
* description="폴더 수정 요청",
|
||||||
|
*
|
||||||
|
* @OA\Property(property="folder_key", type="string", pattern="^[a-z0-9_-]+$", maxLength=50, example="accounting"),
|
||||||
|
* @OA\Property(property="folder_name", type="string", maxLength=100, example="회계"),
|
||||||
|
* @OA\Property(property="description", type="string", nullable=true, maxLength=500, example="회계 관련 문서"),
|
||||||
|
* @OA\Property(property="display_order", type="integer", minimum=0, example=5),
|
||||||
|
* @OA\Property(property="is_active", type="boolean", example=true),
|
||||||
|
* @OA\Property(property="icon", type="string", nullable=true, maxLength=50, example="icon-accounting"),
|
||||||
|
* @OA\Property(property="color", type="string", nullable=true, pattern="^#[0-9A-Fa-f]{6}$", example="#10B981")
|
||||||
|
* )
|
||||||
|
*
|
||||||
|
* @OA\Schema(
|
||||||
|
* schema="FolderReorderRequest",
|
||||||
|
* type="object",
|
||||||
|
* required={"orders"},
|
||||||
|
* description="폴더 순서 변경 요청",
|
||||||
|
*
|
||||||
|
* @OA\Property(
|
||||||
|
* property="orders",
|
||||||
|
* type="array",
|
||||||
|
* description="폴더 ID와 순서 배열",
|
||||||
|
*
|
||||||
|
* @OA\Items(
|
||||||
|
* type="object",
|
||||||
|
* required={"id", "display_order"},
|
||||||
|
*
|
||||||
|
* @OA\Property(property="id", type="integer", example=1),
|
||||||
|
* @OA\Property(property="display_order", type="integer", example=0)
|
||||||
|
* ),
|
||||||
|
* example={
|
||||||
|
* {"id": 1, "display_order": 0},
|
||||||
|
* {"id": 2, "display_order": 1},
|
||||||
|
* {"id": 3, "display_order": 2}
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
class FolderApi
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 폴더 목록 조회
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/folders",
|
||||||
|
* summary="폴더 목록 조회",
|
||||||
|
* description="테넌트의 모든 폴더를 display_order 순으로 조회합니다.",
|
||||||
|
* tags={"Folder"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="폴더 목록 조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Folder"))
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=401, description="인증 실패")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function index() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 생성
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/folders",
|
||||||
|
* summary="폴더 생성",
|
||||||
|
* description="새로운 폴더를 생성합니다.",
|
||||||
|
* tags={"Folder"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/FolderStoreRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="폴더 생성 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Folder")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="유효성 검증 실패"),
|
||||||
|
* @OA\Response(response=409, description="중복된 folder_key")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function store() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 상세 조회
|
||||||
|
*
|
||||||
|
* @OA\Get(
|
||||||
|
* path="/api/v1/folders/{id}",
|
||||||
|
* summary="폴더 상세 조회",
|
||||||
|
* description="폴더 ID로 상세 정보를 조회합니다.",
|
||||||
|
* tags={"Folder"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
* description="폴더 ID",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="폴더 조회 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Folder")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=404, description="폴더를 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function show() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 수정
|
||||||
|
*
|
||||||
|
* @OA\Put(
|
||||||
|
* path="/api/v1/folders/{id}",
|
||||||
|
* summary="폴더 수정",
|
||||||
|
* description="폴더 정보를 수정합니다.",
|
||||||
|
* tags={"Folder"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
* description="폴더 ID",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/FolderUpdateRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="폴더 수정 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Folder")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="유효성 검증 실패"),
|
||||||
|
* @OA\Response(response=404, description="폴더를 찾을 수 없음"),
|
||||||
|
* @OA\Response(response=409, description="중복된 folder_key")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function update() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 삭제/비활성화
|
||||||
|
*
|
||||||
|
* @OA\Delete(
|
||||||
|
* path="/api/v1/folders/{id}",
|
||||||
|
* summary="폴더 삭제",
|
||||||
|
* description="폴더를 비활성화합니다. 파일이 있는 폴더는 삭제할 수 없습니다.",
|
||||||
|
* tags={"Folder"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\Parameter(
|
||||||
|
* name="id",
|
||||||
|
* in="path",
|
||||||
|
* required=true,
|
||||||
|
* description="폴더 ID",
|
||||||
|
*
|
||||||
|
* @OA\Schema(type="integer")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="폴더 삭제 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", ref="#/components/schemas/Folder")
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="폴더에 파일이 있어 삭제 불가"),
|
||||||
|
* @OA\Response(response=404, description="폴더를 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function destroy() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더 순서 변경
|
||||||
|
*
|
||||||
|
* @OA\Post(
|
||||||
|
* path="/api/v1/folders/reorder",
|
||||||
|
* summary="폴더 순서 변경",
|
||||||
|
* description="여러 폴더의 표시 순서를 일괄 변경합니다.",
|
||||||
|
* tags={"Folder"},
|
||||||
|
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
|
||||||
|
*
|
||||||
|
* @OA\RequestBody(
|
||||||
|
* required=true,
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(ref="#/components/schemas/FolderReorderRequest")
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(
|
||||||
|
* response=200,
|
||||||
|
* description="순서 변경 성공",
|
||||||
|
*
|
||||||
|
* @OA\JsonContent(
|
||||||
|
* allOf={
|
||||||
|
*
|
||||||
|
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
|
||||||
|
* @OA\Schema(
|
||||||
|
*
|
||||||
|
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/Folder"))
|
||||||
|
* )
|
||||||
|
* }
|
||||||
|
* )
|
||||||
|
* ),
|
||||||
|
*
|
||||||
|
* @OA\Response(response=400, description="유효성 검증 실패"),
|
||||||
|
* @OA\Response(response=404, description="일부 폴더를 찾을 수 없음")
|
||||||
|
* )
|
||||||
|
*/
|
||||||
|
public function reorder() {}
|
||||||
|
}
|
||||||
@@ -63,4 +63,4 @@ class RefreshApi
|
|||||||
* )
|
* )
|
||||||
*/
|
*/
|
||||||
public function refresh() {}
|
public function refresh() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,8 +77,10 @@
|
|||||||
* property="menus",
|
* property="menus",
|
||||||
* type="array",
|
* type="array",
|
||||||
* description="생성된 테넌트의 기본 메뉴 목록 (9개: 대시보드, 기초정보관리, 제품관리, 거래처관리, BOM관리, 시스템관리, 사용자관리, 권한관리, 부서관리)",
|
* description="생성된 테넌트의 기본 메뉴 목록 (9개: 대시보드, 기초정보관리, 제품관리, 거래처관리, BOM관리, 시스템관리, 사용자관리, 권한관리, 부서관리)",
|
||||||
|
*
|
||||||
* @OA\Items(
|
* @OA\Items(
|
||||||
* type="object",
|
* type="object",
|
||||||
|
*
|
||||||
* @OA\Property(property="id", type="integer", example=1, description="메뉴 ID"),
|
* @OA\Property(property="id", type="integer", example=1, description="메뉴 ID"),
|
||||||
* @OA\Property(property="parent_id", type="integer", example=null, nullable=true, description="상위 메뉴 ID (최상위 메뉴는 null)"),
|
* @OA\Property(property="parent_id", type="integer", example=null, nullable=true, description="상위 메뉴 ID (최상위 메뉴는 null)"),
|
||||||
* @OA\Property(property="name", type="string", example="대시보드", description="메뉴명"),
|
* @OA\Property(property="name", type="string", example="대시보드", description="메뉴명"),
|
||||||
|
|||||||
@@ -47,6 +47,14 @@
|
|||||||
'report' => false,
|
'report' => false,
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'tenant' => [
|
||||||
|
'driver' => 'local',
|
||||||
|
'root' => storage_path('app/tenants'),
|
||||||
|
'visibility' => 'private',
|
||||||
|
'throw' => false,
|
||||||
|
'report' => false,
|
||||||
|
],
|
||||||
|
|
||||||
's3' => [
|
's3' => [
|
||||||
'driver' => 's3',
|
'driver' => 's3',
|
||||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||||
@@ -62,6 +70,82 @@
|
|||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| File Upload Constraints
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure file upload constraints such as maximum file size and
|
||||||
|
| allowed file types for the file storage system.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'file_constraints' => [
|
||||||
|
'max_file_size' => env('FILE_MAX_SIZE', 20 * 1024 * 1024), // 20MB default
|
||||||
|
'allowed_extensions' => [
|
||||||
|
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
|
||||||
|
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg',
|
||||||
|
'zip', 'rar', '7z', 'tar', 'gz',
|
||||||
|
'txt', 'csv', 'xml', 'json',
|
||||||
|
],
|
||||||
|
'allowed_mimes' => [
|
||||||
|
'application/pdf',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-powerpoint',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/bmp',
|
||||||
|
'image/svg+xml',
|
||||||
|
'application/zip',
|
||||||
|
'application/x-rar-compressed',
|
||||||
|
'application/x-7z-compressed',
|
||||||
|
'application/x-tar',
|
||||||
|
'application/gzip',
|
||||||
|
'text/plain',
|
||||||
|
'text/csv',
|
||||||
|
'application/xml',
|
||||||
|
'application/json',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Storage Policies
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure storage quota policies, cleanup schedules, and retention
|
||||||
|
| periods for the file storage system.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'storage_policies' => [
|
||||||
|
'default_limit' => env('STORAGE_DEFAULT_LIMIT', 10 * 1024 * 1024 * 1024), // 10GB
|
||||||
|
'warning_threshold' => 0.9, // 90%
|
||||||
|
'grace_period_days' => 7,
|
||||||
|
'temp_cleanup_days' => 7,
|
||||||
|
'trash_retention_days' => 30,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Share Link Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure default settings for file share links including expiry
|
||||||
|
| time and maximum download limits.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'share_link' => [
|
||||||
|
'expiry_hours' => env('SHARE_LINK_EXPIRY_HOURS', 24),
|
||||||
|
'max_downloads' => env('SHARE_LINK_MAX_DOWNLOADS', null), // null = unlimited
|
||||||
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Symbolic Links
|
| Symbolic Links
|
||||||
|
|||||||
162
database/migrations/2025_11_10_190208_enhance_files_table.php
Normal file
162
database/migrations/2025_11_10_190208_enhance_files_table.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
// Legacy 컬럼 nullable 변경 (하위 호환성) - 한 번에 처리
|
||||||
|
$legacyColumns = [
|
||||||
|
'original_name' => 'string',
|
||||||
|
'file_name' => 'string',
|
||||||
|
'file_name_old' => 'string',
|
||||||
|
'fileable_id' => 'bigInteger',
|
||||||
|
'fileable_type' => 'string',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($legacyColumns as $column => $type) {
|
||||||
|
if (Schema::hasColumn('files', $column)) {
|
||||||
|
if ($type === 'bigInteger') {
|
||||||
|
$table->unsignedBigInteger($column)->nullable()->change();
|
||||||
|
} else {
|
||||||
|
$table->string($column, 255)->nullable()->change();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일명 시스템 개선
|
||||||
|
if (! Schema::hasColumn('files', 'display_name')) {
|
||||||
|
$table->string('display_name', 255)->after('file_path')->comment('사용자가 보는 이름');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('files', 'stored_name')) {
|
||||||
|
$table->string('stored_name', 255)->after('display_name')->comment('실제 저장 이름 (64bit 난수)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 폴더 관리
|
||||||
|
if (! Schema::hasColumn('files', 'folder_id')) {
|
||||||
|
$table->unsignedBigInteger('folder_id')->nullable()->after('tenant_id')->comment('folders 테이블 FK');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('files', 'is_temp')) {
|
||||||
|
$table->boolean('is_temp')->default(true)->after('folder_id')->comment('temp 폴더 여부');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파일 분류
|
||||||
|
if (! Schema::hasColumn('files', 'file_type')) {
|
||||||
|
$table->enum('file_type', ['document', 'image', 'excel', 'archive'])->after('mime_type')->comment('파일 타입');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 문서 연결
|
||||||
|
if (! Schema::hasColumn('files', 'document_id')) {
|
||||||
|
$table->unsignedBigInteger('document_id')->nullable()->after('file_type')->comment('문서 ID');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('files', 'document_type')) {
|
||||||
|
$table->string('document_type', 50)->nullable()->after('document_id')->comment('문서 타입');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 감사 컬럼
|
||||||
|
if (! Schema::hasColumn('files', 'uploaded_by')) {
|
||||||
|
$table->unsignedBigInteger('uploaded_by')->nullable()->after('description')->comment('업로더 user_id');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('files', 'deleted_by')) {
|
||||||
|
$table->unsignedBigInteger('deleted_by')->nullable()->after('deleted_at')->comment('삭제자 user_id');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('files', 'created_by')) {
|
||||||
|
$table->unsignedBigInteger('created_by')->nullable()->after('deleted_by')->comment('생성자 user_id');
|
||||||
|
}
|
||||||
|
if (! Schema::hasColumn('files', 'updated_by')) {
|
||||||
|
$table->unsignedBigInteger('updated_by')->nullable()->after('created_by')->comment('수정자 user_id');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 인덱스 추가
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
$table->index(['tenant_id', 'folder_id'], 'idx_tenant_folder');
|
||||||
|
$table->index('is_temp');
|
||||||
|
$table->index('document_id');
|
||||||
|
$table->index('created_at');
|
||||||
|
$table->index('stored_name');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 인덱스 삭제 (에러 무시)
|
||||||
|
try {
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_tenant_folder');
|
||||||
|
});
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 인덱스가 없으면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['is_temp']);
|
||||||
|
});
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 인덱스가 없으면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['document_id']);
|
||||||
|
});
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 인덱스가 없으면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['created_at']);
|
||||||
|
});
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 인덱스가 없으면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['stored_name']);
|
||||||
|
});
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 인덱스가 없으면 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 삭제 (존재하는 것만)
|
||||||
|
Schema::table('files', function (Blueprint $table) {
|
||||||
|
$columnsToCheck = [
|
||||||
|
'display_name',
|
||||||
|
'stored_name',
|
||||||
|
'folder_id',
|
||||||
|
'is_temp',
|
||||||
|
'file_type',
|
||||||
|
'document_id',
|
||||||
|
'document_type',
|
||||||
|
'uploaded_by',
|
||||||
|
'deleted_by',
|
||||||
|
'created_by',
|
||||||
|
'updated_by',
|
||||||
|
];
|
||||||
|
|
||||||
|
$columnsToDrop = [];
|
||||||
|
foreach ($columnsToCheck as $column) {
|
||||||
|
if (Schema::hasColumn('files', $column)) {
|
||||||
|
$columnsToDrop[] = $column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($columnsToDrop)) {
|
||||||
|
$table->dropColumn($columnsToDrop);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('folders', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||||
|
|
||||||
|
// 폴더 정보
|
||||||
|
$table->string('folder_key', 50)->comment('폴더 키 (product, quality, accounting)');
|
||||||
|
$table->string('folder_name', 100)->comment('폴더명 (생산관리, 품질관리, 회계)');
|
||||||
|
$table->text('description')->nullable()->comment('설명');
|
||||||
|
|
||||||
|
// 순서 및 표시
|
||||||
|
$table->integer('display_order')->default(0)->comment('표시 순서');
|
||||||
|
$table->boolean('is_active')->default(true)->comment('활성 여부');
|
||||||
|
|
||||||
|
// UI 커스터마이징 (선택)
|
||||||
|
$table->string('icon', 50)->nullable()->comment('아이콘 (icon-production, icon-quality)');
|
||||||
|
$table->string('color', 20)->nullable()->comment('색상 (#3B82F6)');
|
||||||
|
|
||||||
|
// 감사 컬럼
|
||||||
|
$table->unsignedBigInteger('created_by')->nullable();
|
||||||
|
$table->unsignedBigInteger('updated_by')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
$table->unique(['tenant_id', 'folder_key'], 'uq_tenant_folder_key');
|
||||||
|
$table->index(['tenant_id', 'is_active'], 'idx_active');
|
||||||
|
$table->index(['tenant_id', 'display_order'], 'idx_display_order');
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('folders');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
// 용량 관리
|
||||||
|
$table->bigInteger('storage_limit')->default(10737418240)->comment('저장소 한도 (10GB)');
|
||||||
|
$table->bigInteger('storage_used')->default(0)->comment('사용 중인 용량 (bytes)');
|
||||||
|
$table->timestamp('storage_warning_sent_at')->nullable()->comment('90% 경고 발송 시간');
|
||||||
|
$table->timestamp('storage_grace_period_until')->nullable()->comment('유예 기간 종료 시간 (7일)');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
$table->index(['storage_used'], 'idx_storage_used');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tenants', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_storage_used');
|
||||||
|
$table->dropColumn([
|
||||||
|
'storage_limit',
|
||||||
|
'storage_used',
|
||||||
|
'storage_warning_sent_at',
|
||||||
|
'storage_grace_period_until',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('file_deletion_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||||
|
$table->unsignedBigInteger('file_id')->comment('파일 ID');
|
||||||
|
|
||||||
|
// 파일 정보 (삭제 시점 스냅샷)
|
||||||
|
$table->string('file_name', 255)->comment('파일명 (display_name)');
|
||||||
|
$table->string('file_path', 1000)->comment('파일 경로');
|
||||||
|
$table->bigInteger('file_size')->comment('파일 크기 (bytes)');
|
||||||
|
|
||||||
|
// 연관 정보
|
||||||
|
$table->unsignedBigInteger('folder_id')->nullable()->comment('폴더 ID');
|
||||||
|
$table->unsignedBigInteger('document_id')->nullable()->comment('문서 ID');
|
||||||
|
$table->string('document_type', 100)->nullable()->comment('문서 타입');
|
||||||
|
|
||||||
|
// 삭제 정보
|
||||||
|
$table->unsignedBigInteger('deleted_by')->comment('삭제자 ID');
|
||||||
|
$table->timestamp('deleted_at')->comment('삭제 시간');
|
||||||
|
$table->enum('deletion_type', ['soft', 'permanent'])->default('soft')->comment('삭제 유형');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
$table->index(['tenant_id'], 'idx_tenant');
|
||||||
|
$table->index(['file_id'], 'idx_file');
|
||||||
|
$table->index(['deleted_at'], 'idx_deleted_at');
|
||||||
|
$table->index(['deletion_type'], 'idx_deletion_type');
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('file_deletion_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('file_share_links', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('file_id')->comment('파일 ID');
|
||||||
|
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||||
|
|
||||||
|
// 공유 링크 정보
|
||||||
|
$table->string('token', 64)->unique()->comment('공유 토큰 (64자 랜덤)');
|
||||||
|
$table->timestamp('expires_at')->comment('만료 시간 (24시간 기본)');
|
||||||
|
|
||||||
|
// 다운로드 추적
|
||||||
|
$table->integer('download_count')->default(0)->comment('다운로드 횟수');
|
||||||
|
$table->integer('max_downloads')->nullable()->comment('최대 다운로드 횟수 (null=무제한)');
|
||||||
|
$table->timestamp('last_downloaded_at')->nullable()->comment('마지막 다운로드 시간');
|
||||||
|
$table->string('last_downloaded_ip', 45)->nullable()->comment('마지막 다운로드 IP');
|
||||||
|
|
||||||
|
// 감사 정보
|
||||||
|
$table->unsignedBigInteger('created_by')->comment('생성자 ID');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
$table->index(['file_id'], 'idx_file');
|
||||||
|
$table->index(['tenant_id'], 'idx_tenant');
|
||||||
|
$table->index(['token'], 'idx_token');
|
||||||
|
$table->index(['expires_at'], 'idx_expires');
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
$table->foreign('file_id')->references('id')->on('files')->onDelete('cascade');
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('file_share_links');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('storage_usage_history', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
|
||||||
|
|
||||||
|
// 용량 정보
|
||||||
|
$table->bigInteger('storage_used')->comment('사용량 (bytes)');
|
||||||
|
$table->integer('file_count')->comment('파일 개수');
|
||||||
|
|
||||||
|
// 폴더별 상세 (JSON)
|
||||||
|
$table->json('folder_usage')->nullable()->comment('폴더별 용량 {"product": 1024000, "quality": 512000}');
|
||||||
|
|
||||||
|
// 기록 시간
|
||||||
|
$table->timestamp('recorded_at')->useCurrent()->comment('기록 시간');
|
||||||
|
|
||||||
|
// 인덱스
|
||||||
|
$table->index(['tenant_id', 'recorded_at'], 'idx_tenant_recorded');
|
||||||
|
|
||||||
|
// 외래키
|
||||||
|
$table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('storage_usage_history');
|
||||||
|
}
|
||||||
|
};
|
||||||
91
database/seeders/FolderSeeder.php
Normal file
91
database/seeders/FolderSeeder.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class FolderSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php artisan db:seed --class=FolderSeeder
|
||||||
|
* or from code: (new FolderSeeder)->run(tenantId: 1)
|
||||||
|
*/
|
||||||
|
public function run(?int $tenantId = null): void
|
||||||
|
{
|
||||||
|
// 테넌트 ID가 지정되지 않으면 모든 테넌트에 대해 실행
|
||||||
|
$tenants = $tenantId
|
||||||
|
? [\App\Models\Tenants\Tenant::findOrFail($tenantId)]
|
||||||
|
: \App\Models\Tenants\Tenant::all();
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
// 이미 폴더가 있는지 확인
|
||||||
|
if (\App\Models\Folder::where('tenant_id', $tenant->id)->exists()) {
|
||||||
|
$this->command->info("Tenant {$tenant->id} already has folders. Skipping...");
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 폴더 생성
|
||||||
|
$defaultFolders = [
|
||||||
|
[
|
||||||
|
'folder_key' => 'product',
|
||||||
|
'folder_name' => '생산관리',
|
||||||
|
'description' => '생산 관련 문서 및 도면',
|
||||||
|
'icon' => 'icon-production',
|
||||||
|
'color' => '#3B82F6',
|
||||||
|
'display_order' => 1,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'quality',
|
||||||
|
'folder_name' => '품질관리',
|
||||||
|
'description' => '품질 검사 및 인증 문서',
|
||||||
|
'icon' => 'icon-quality',
|
||||||
|
'color' => '#10B981',
|
||||||
|
'display_order' => 2,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'accounting',
|
||||||
|
'folder_name' => '회계',
|
||||||
|
'description' => '회계 관련 증빙 서류',
|
||||||
|
'icon' => 'icon-accounting',
|
||||||
|
'color' => '#F59E0B',
|
||||||
|
'display_order' => 3,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'hr',
|
||||||
|
'folder_name' => '인사',
|
||||||
|
'description' => '인사 관련 문서',
|
||||||
|
'icon' => 'icon-hr',
|
||||||
|
'color' => '#8B5CF6',
|
||||||
|
'display_order' => 4,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'folder_key' => 'general',
|
||||||
|
'folder_name' => '일반',
|
||||||
|
'description' => '기타 문서',
|
||||||
|
'icon' => 'icon-general',
|
||||||
|
'color' => '#6B7280',
|
||||||
|
'display_order' => 5,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($defaultFolders as $folder) {
|
||||||
|
\App\Models\Folder::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'folder_key' => $folder['folder_key'],
|
||||||
|
'folder_name' => $folder['folder_name'],
|
||||||
|
'description' => $folder['description'],
|
||||||
|
'icon' => $folder['icon'],
|
||||||
|
'color' => $folder['color'],
|
||||||
|
'display_order' => $folder['display_order'],
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->command->info("Created default folders for tenant {$tenant->id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,26 @@
|
|||||||
'file_too_large' => '파일 크기가 너무 큽니다.',
|
'file_too_large' => '파일 크기가 너무 큽니다.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// 파일 저장소 관련
|
||||||
|
'file_not_found' => '파일을 찾을 수 없습니다.',
|
||||||
|
'file_ids_required' => '파일 ID 목록은 필수입니다.',
|
||||||
|
'file_ids_must_be_array' => '파일 ID는 배열이어야 합니다.',
|
||||||
|
'folder_not_found' => '폴더를 찾을 수 없습니다.',
|
||||||
|
'folder_id_required' => '폴더 ID는 필수입니다.',
|
||||||
|
'storage_quota_exceeded' => '저장소 용량이 부족합니다.',
|
||||||
|
'share_link_expired' => '공유 링크가 만료되었습니다.',
|
||||||
|
'share_link_not_found' => '공유 링크를 찾을 수 없습니다.',
|
||||||
|
|
||||||
|
// 폴더 관련
|
||||||
|
'folder_key_required' => '폴더 키는 필수입니다.',
|
||||||
|
'folder_key_duplicate' => '이미 존재하는 폴더 키입니다.',
|
||||||
|
'folder_key_format' => '폴더 키는 영문 소문자, 숫자, 하이픈, 언더스코어만 사용할 수 있습니다.',
|
||||||
|
'folder_name_required' => '폴더명은 필수입니다.',
|
||||||
|
'folder_has_files' => '폴더에 파일이 있어 삭제할 수 없습니다.',
|
||||||
|
'color_format' => '색상 코드는 #RRGGBB 형식이어야 합니다.',
|
||||||
|
'expiry_hours_min' => '만료 시간은 최소 1시간입니다.',
|
||||||
|
'expiry_hours_max' => '만료 시간은 최대 168시간(7일)입니다.',
|
||||||
|
|
||||||
// 가격 관리 관련
|
// 가격 관리 관련
|
||||||
'price_not_found' => ':item_type ID :item_id 항목의 :date 기준 매출단가를 찾을 수 없습니다.',
|
'price_not_found' => ':item_type ID :item_id 항목의 :date 기준 매출단가를 찾을 수 없습니다.',
|
||||||
|
|
||||||
|
|||||||
@@ -134,4 +134,19 @@
|
|||||||
'deleted' => '파일이 삭제되었습니다.',
|
'deleted' => '파일이 삭제되었습니다.',
|
||||||
'fetched' => '파일 목록을 조회했습니다.',
|
'fetched' => '파일 목록을 조회했습니다.',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// 파일 저장소
|
||||||
|
'file_uploaded' => '파일이 업로드되었습니다.',
|
||||||
|
'files_moved' => '파일이 이동되었습니다.',
|
||||||
|
'file_deleted' => '파일이 삭제되었습니다.',
|
||||||
|
'file_restored' => '파일이 복구되었습니다.',
|
||||||
|
'file_permanently_deleted' => '파일이 영구 삭제되었습니다.',
|
||||||
|
'share_link_created' => '공유 링크가 생성되었습니다.',
|
||||||
|
'storage_exceeded_grace_period' => '저장소 용량이 초과되었습니다. 유예 기간이 적용됩니다.',
|
||||||
|
|
||||||
|
// 폴더 관리
|
||||||
|
'folder_created' => '폴더가 생성되었습니다.',
|
||||||
|
'folder_updated' => '폴더가 수정되었습니다.',
|
||||||
|
'folder_deleted' => '폴더가 비활성화되었습니다.',
|
||||||
|
'folders_reordered' => '폴더 순서가 변경되었습니다.',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -184,4 +184,4 @@
|
|||||||
'tenant_id' => '테넌트',
|
'tenant_id' => '테넌트',
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Api\V1\AdminController;
|
use App\Http\Controllers\Api\V1\AdminController;
|
||||||
use App\Http\Controllers\Api\V1\ApiController;
|
use App\Http\Controllers\Api\V1\ApiController;
|
||||||
use App\Http\Controllers\Api\V1\RefreshController;
|
|
||||||
use App\Http\Controllers\Api\V1\CategoryController;
|
use App\Http\Controllers\Api\V1\CategoryController;
|
||||||
use App\Http\Controllers\Api\V1\CategoryFieldController;
|
use App\Http\Controllers\Api\V1\CategoryFieldController;
|
||||||
use App\Http\Controllers\Api\V1\CategoryLogController;
|
use App\Http\Controllers\Api\V1\CategoryLogController;
|
||||||
@@ -18,7 +17,8 @@
|
|||||||
use App\Http\Controllers\Api\V1\Design\DesignModelController;
|
use App\Http\Controllers\Api\V1\Design\DesignModelController;
|
||||||
use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController;
|
use App\Http\Controllers\Api\V1\Design\ModelVersionController as DesignModelVersionController;
|
||||||
use App\Http\Controllers\Api\V1\EstimateController;
|
use App\Http\Controllers\Api\V1\EstimateController;
|
||||||
use App\Http\Controllers\Api\V1\FileController;
|
use App\Http\Controllers\Api\V1\FileStorageController;
|
||||||
|
use App\Http\Controllers\Api\V1\FolderController;
|
||||||
use App\Http\Controllers\Api\V1\MaterialController;
|
use App\Http\Controllers\Api\V1\MaterialController;
|
||||||
use App\Http\Controllers\Api\V1\MenuController;
|
use App\Http\Controllers\Api\V1\MenuController;
|
||||||
use App\Http\Controllers\Api\V1\ModelSetController;
|
use App\Http\Controllers\Api\V1\ModelSetController;
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
use App\Http\Controllers\Api\V1\PricingController;
|
use App\Http\Controllers\Api\V1\PricingController;
|
||||||
use App\Http\Controllers\Api\V1\ProductBomItemController;
|
use App\Http\Controllers\Api\V1\ProductBomItemController;
|
||||||
use App\Http\Controllers\Api\V1\ProductController;
|
use App\Http\Controllers\Api\V1\ProductController;
|
||||||
|
use App\Http\Controllers\Api\V1\RefreshController;
|
||||||
use App\Http\Controllers\Api\V1\RegisterController;
|
use App\Http\Controllers\Api\V1\RegisterController;
|
||||||
use App\Http\Controllers\Api\V1\RoleController;
|
use App\Http\Controllers\Api\V1\RoleController;
|
||||||
use App\Http\Controllers\Api\V1\RolePermissionController;
|
use App\Http\Controllers\Api\V1\RolePermissionController;
|
||||||
@@ -104,14 +105,6 @@
|
|||||||
Route::put('/restore/{tenant_id}', [TenantController::class, 'restore'])->name('v1.tenant.restore'); // 테넌트 복구
|
Route::put('/restore/{tenant_id}', [TenantController::class, 'restore'])->name('v1.tenant.restore'); // 테넌트 복구
|
||||||
});
|
});
|
||||||
|
|
||||||
// File API
|
|
||||||
Route::prefix('file')->group(function () {
|
|
||||||
Route::post('upload', [FileController::class, 'upload'])->name('v1.file.upload'); // 파일 업로드 (등록/수정)
|
|
||||||
Route::get('list', [FileController::class, 'list'])->name('v1.file.list'); // 파일 목록 조회
|
|
||||||
Route::delete('delete', [FileController::class, 'delete'])->name('v1.file.delete'); // 파일 삭제
|
|
||||||
Route::get('info', [FileController::class, 'findFile'])->name('v1.file.info'); // 파일 정보 조회
|
|
||||||
});
|
|
||||||
|
|
||||||
// Menu API
|
// Menu API
|
||||||
Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () {
|
Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () {
|
||||||
Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index');
|
Route::get('/', [MenuController::class, 'index'])->name('v1.menus.index');
|
||||||
@@ -407,5 +400,35 @@
|
|||||||
Route::post('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기
|
Route::post('/preview/{model_set_id}', [EstimateController::class, 'previewCalculation'])->name('v1.estimates.preview'); // 견적 계산 미리보기
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 파일 저장소 API
|
||||||
|
Route::prefix('files')->group(function () {
|
||||||
|
Route::post('/upload', [FileStorageController::class, 'upload'])->name('v1.files.upload'); // 파일 업로드 (임시)
|
||||||
|
Route::post('/move', [FileStorageController::class, 'move'])->name('v1.files.move'); // 파일 이동 (temp → folder)
|
||||||
|
Route::get('/', [FileStorageController::class, 'index'])->name('v1.files.index'); // 파일 목록
|
||||||
|
Route::get('/trash', [FileStorageController::class, 'trash'])->name('v1.files.trash'); // 휴지통 목록
|
||||||
|
Route::get('/{id}', [FileStorageController::class, 'show'])->name('v1.files.show'); // 파일 상세
|
||||||
|
Route::get('/{id}/download', [FileStorageController::class, 'download'])->name('v1.files.download'); // 파일 다운로드
|
||||||
|
Route::delete('/{id}', [FileStorageController::class, 'destroy'])->name('v1.files.destroy'); // 파일 삭제 (soft)
|
||||||
|
Route::post('/{id}/restore', [FileStorageController::class, 'restore'])->name('v1.files.restore'); // 파일 복구
|
||||||
|
Route::delete('/{id}/permanent', [FileStorageController::class, 'permanentDelete'])->name('v1.files.permanent'); // 파일 영구 삭제
|
||||||
|
Route::post('/{id}/share', [FileStorageController::class, 'createShareLink'])->name('v1.files.share'); // 공유 링크 생성
|
||||||
|
});
|
||||||
|
|
||||||
|
// 저장소 사용량
|
||||||
|
Route::get('/storage/usage', [FileStorageController::class, 'storageUsage'])->name('v1.storage.usage');
|
||||||
|
|
||||||
|
// 폴더 관리 API
|
||||||
|
Route::prefix('folders')->group(function () {
|
||||||
|
Route::get('/', [FolderController::class, 'index'])->name('v1.folders.index'); // 폴더 목록
|
||||||
|
Route::post('/', [FolderController::class, 'store'])->name('v1.folders.store'); // 폴더 생성
|
||||||
|
Route::get('/{id}', [FolderController::class, 'show'])->name('v1.folders.show'); // 폴더 상세
|
||||||
|
Route::put('/{id}', [FolderController::class, 'update'])->name('v1.folders.update'); // 폴더 수정
|
||||||
|
Route::delete('/{id}', [FolderController::class, 'destroy'])->name('v1.folders.destroy'); // 폴더 삭제/비활성화
|
||||||
|
Route::post('/reorder', [FolderController::class, 'reorder'])->name('v1.folders.reorder'); // 폴더 순서 변경
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
|
||||||
|
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,3 +30,47 @@
|
|||||||
->onFailure(function () {
|
->onFailure(function () {
|
||||||
\Illuminate\Support\Facades\Log::error('❌ sanctum:prune-expired 스케줄러 실행 실패', ['time' => now()]);
|
\Illuminate\Support\Facades\Log::error('❌ sanctum:prune-expired 스케줄러 실행 실패', ['time' => now()]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 매일 새벽 03:30에 7일 이상 된 임시 파일 정리
|
||||||
|
Schedule::command('storage:cleanup-temp')
|
||||||
|
->dailyAt('03:30')
|
||||||
|
->appendOutputTo(storage_path('logs/scheduler.log'))
|
||||||
|
->onSuccess(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::info('✅ storage:cleanup-temp 스케줄러 실행 성공', ['time' => now()]);
|
||||||
|
})
|
||||||
|
->onFailure(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::error('❌ storage:cleanup-temp 스케줄러 실행 실패', ['time' => now()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매일 새벽 03:40에 휴지통 파일 영구 삭제 (30일 이상)
|
||||||
|
Schedule::command('storage:cleanup-trash')
|
||||||
|
->dailyAt('03:40')
|
||||||
|
->appendOutputTo(storage_path('logs/scheduler.log'))
|
||||||
|
->onSuccess(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::info('✅ storage:cleanup-trash 스케줄러 실행 성공', ['time' => now()]);
|
||||||
|
})
|
||||||
|
->onFailure(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::error('❌ storage:cleanup-trash 스케줄러 실행 실패', ['time' => now()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매일 새벽 03:50에 만료된 공유 링크 정리
|
||||||
|
Schedule::command('storage:cleanup-links')
|
||||||
|
->dailyAt('03:50')
|
||||||
|
->appendOutputTo(storage_path('logs/scheduler.log'))
|
||||||
|
->onSuccess(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::info('✅ storage:cleanup-links 스케줄러 실행 성공', ['time' => now()]);
|
||||||
|
})
|
||||||
|
->onFailure(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::error('❌ storage:cleanup-links 스케줄러 실행 실패', ['time' => now()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 매일 새벽 04:00에 용량 사용 히스토리 기록
|
||||||
|
Schedule::command('storage:record-usage')
|
||||||
|
->dailyAt('04:00')
|
||||||
|
->appendOutputTo(storage_path('logs/scheduler.log'))
|
||||||
|
->onSuccess(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::info('✅ storage:record-usage 스케줄러 실행 성공', ['time' => now()]);
|
||||||
|
})
|
||||||
|
->onFailure(function () {
|
||||||
|
\Illuminate\Support\Facades\Log::error('❌ storage:record-usage 스케줄러 실행 실패', ['time' => now()]);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user