From c83e0294487587f67e7ae4e32597373d37281a4e Mon Sep 17 00:00:00 2001 From: hskwon Date: Mon, 10 Nov 2025 19:08:56 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=8C=8C=EC=9D=BC=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20DB=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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개 기본 폴더 (생산관리, 품질관리, 회계, 인사, 일반) - 에러 처리 및 로깅 - 회원가입 시 자동 실행 --- CURRENT_WORKS.md | 618 ++++++++++++++++++ LOGICAL_RELATIONSHIPS.md | 12 +- app/Console/Commands/CleanupExpiredLinks.php | 39 ++ app/Console/Commands/CleanupTempFiles.php | 59 ++ app/Console/Commands/CleanupTrash.php | 83 +++ app/Console/Commands/RecordStorageUsage.php | 77 +++ app/Helpers/ApiResponse.php | 8 +- app/Helpers/TenantCodeGenerator.php | 2 +- .../Controllers/Api/V1/CategoryController.php | 2 +- .../Controllers/Api/V1/ClientController.php | 3 +- .../Controllers/Api/V1/FileController.php | 294 --------- .../Api/V1/FileStorageController.php | 174 +++++ .../Controllers/Api/V1/FolderController.php | 88 +++ .../Controllers/Api/V1/MaterialController.php | 2 +- .../Controllers/Api/V1/RefreshController.php | 5 +- .../Controllers/Api/V1/TenantController.php | 2 +- .../Controllers/Api/V1/UserController.php | 2 +- app/Http/Requests/Api/V1/FileMoveRequest.php | 35 + .../Requests/Api/V1/FileUploadRequest.php | 39 ++ .../Requests/Api/V1/FolderStoreRequest.php | 46 ++ .../Requests/Api/V1/FolderUpdateRequest.php | 47 ++ app/Http/Requests/Api/V1/RefreshRequest.php | 2 +- app/Http/Requests/Api/V1/ShareLinkRequest.php | 41 ++ .../Requests/Category/CategoryMoveRequest.php | 2 +- .../Category/CategoryReorderRequest.php | 2 +- .../Category/CategoryStoreRequest.php | 2 +- .../Category/CategoryUpdateRequest.php | 2 +- .../Requests/Client/ClientStoreRequest.php | 2 +- .../Requests/Client/ClientUpdateRequest.php | 2 +- .../Material/MaterialStoreRequest.php | 2 +- .../Material/MaterialUpdateRequest.php | 2 +- .../Requests/Product/ProductStoreRequest.php | 2 +- .../Requests/Product/ProductUpdateRequest.php | 2 +- .../Requests/Tenant/TenantStoreRequest.php | 2 +- .../Requests/Tenant/TenantUpdateRequest.php | 2 +- .../Requests/User/PasswordChangeRequest.php | 2 +- .../Requests/User/SwitchTenantRequest.php | 2 +- app/Http/Requests/User/UserUpdateRequest.php | 2 +- app/Models/Commons/File.php | 196 +++++- app/Models/FileShareLink.php | 122 ++++ app/Models/Folder.php | 63 ++ app/Models/Tenants/Tenant.php | 125 ++++ app/Observers/TenantObserver.php | 69 ++ app/Services/AuthService.php | 5 +- app/Services/FileStorageService.php | 351 ++++++++++ app/Services/FolderService.php | 109 +++ app/Services/MenuBootstrapService.php | 2 +- app/Swagger/v1/FileApi.php | 527 ++++++++++++++- app/Swagger/v1/FolderApi.php | 324 +++++++++ app/Swagger/v1/RefreshApi.php | 2 +- app/Swagger/v1/RegisterApi.php | 2 + config/filesystems.php | 84 +++ .../2025_11_10_190208_enhance_files_table.php | 162 +++++ ...2025_11_10_190257_create_folders_table.php | 53 ++ ..._190355_add_storage_columns_to_tenants.php | 41 ++ ...190355_create_file_deletion_logs_table.php | 52 ++ ...0_190355_create_file_share_links_table.php | 52 ++ ...355_create_storage_usage_history_table.php | 43 ++ database/seeders/FolderSeeder.php | 91 +++ lang/ko/error.php | 20 + lang/ko/message.php | 15 + lang/ko/validation.php | 2 +- routes/api.php | 43 +- routes/console.php | 44 ++ 64 files changed, 3960 insertions(+), 349 deletions(-) create mode 100644 app/Console/Commands/CleanupExpiredLinks.php create mode 100644 app/Console/Commands/CleanupTempFiles.php create mode 100644 app/Console/Commands/CleanupTrash.php create mode 100644 app/Console/Commands/RecordStorageUsage.php delete mode 100644 app/Http/Controllers/Api/V1/FileController.php create mode 100644 app/Http/Controllers/Api/V1/FileStorageController.php create mode 100644 app/Http/Controllers/Api/V1/FolderController.php create mode 100644 app/Http/Requests/Api/V1/FileMoveRequest.php create mode 100644 app/Http/Requests/Api/V1/FileUploadRequest.php create mode 100644 app/Http/Requests/Api/V1/FolderStoreRequest.php create mode 100644 app/Http/Requests/Api/V1/FolderUpdateRequest.php create mode 100644 app/Http/Requests/Api/V1/ShareLinkRequest.php create mode 100644 app/Models/FileShareLink.php create mode 100644 app/Models/Folder.php create mode 100644 app/Services/FileStorageService.php create mode 100644 app/Services/FolderService.php create mode 100644 app/Swagger/v1/FolderApi.php create mode 100644 database/migrations/2025_11_10_190208_enhance_files_table.php create mode 100644 database/migrations/2025_11_10_190257_create_folders_table.php create mode 100644 database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php create mode 100644 database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php create mode 100644 database/migrations/2025_11_10_190355_create_file_share_links_table.php create mode 100644 database/migrations/2025_11_10_190355_create_storage_usage_history_table.php create mode 100644 database/seeders/FolderSeeder.php diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index 11ee54d..4a87bb0 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,5 +1,623 @@ # 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 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리 + 자동 정리) ### 주요 작업 diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index def9283..a6a25f3 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-10-14 22:24:19 +> **자동 생성**: 2025-11-10 21:01:46 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -63,7 +63,9 @@ ### category_templates ### files **모델**: `App\Models\Commons\File` +- **folder()**: belongsTo → `folders` - **uploader()**: belongsTo → `users` +- **shareLinks()**: hasMany → `file_share_links` - **fileable()**: morphTo → `(Polymorphic)` ### menus @@ -110,6 +112,14 @@ ### estimate_items - **estimate()**: belongsTo → `estimates` +### file_share_links +**모델**: `App\Models\FileShareLink` + + +### folders +**모델**: `App\Models\Folder` + + ### main_requests **모델**: `App\Models\MainRequest` diff --git a/app/Console/Commands/CleanupExpiredLinks.php b/app/Console/Commands/CleanupExpiredLinks.php new file mode 100644 index 0000000..ac08bff --- /dev/null +++ b/app/Console/Commands/CleanupExpiredLinks.php @@ -0,0 +1,39 @@ +subDays(7); + + $deleted = DB::table('file_share_links') + ->where('expires_at', '<', $threshold) + ->delete(); + + $this->info("Deleted {$deleted} expired share links"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/CleanupTempFiles.php b/app/Console/Commands/CleanupTempFiles.php new file mode 100644 index 0000000..ae40410 --- /dev/null +++ b/app/Console/Commands/CleanupTempFiles.php @@ -0,0 +1,59 @@ +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; + } +} diff --git a/app/Console/Commands/CleanupTrash.php b/app/Console/Commands/CleanupTrash.php new file mode 100644 index 0000000..8ed9e58 --- /dev/null +++ b/app/Console/Commands/CleanupTrash.php @@ -0,0 +1,83 @@ +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(); + }); + } +} diff --git a/app/Console/Commands/RecordStorageUsage.php b/app/Console/Commands/RecordStorageUsage.php new file mode 100644 index 0000000..1aaf2fc --- /dev/null +++ b/app/Console/Commands/RecordStorageUsage.php @@ -0,0 +1,77 @@ +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; + } +} diff --git a/app/Helpers/ApiResponse.php b/app/Helpers/ApiResponse.php index b7a285c..20011d5 100644 --- a/app/Helpers/ApiResponse.php +++ b/app/Helpers/ApiResponse.php @@ -142,7 +142,7 @@ public static function handle( ) ) { $code = (int) ($result['code'] ?? 400); - $message = (string) ($result['message'] ?? ($result['error'] ?? ($responseTitle.' 실패'))); + $message = (string) ($result['message'] ?? ($result['error'] ?? '서버 에러')); $details = $result['details'] ?? null; // 에러에도 쿼리 로그 포함되도록 error()가 처리하게 맡김 @@ -161,20 +161,20 @@ public static function handle( : []; } - return self::success($data, $responseTitle.' 성공', $debug); + return self::success($data, $responseTitle, $debug); } catch (\Throwable $e) { // HttpException 계열은 상태코드/메시지를 그대로 반영 if ($e instanceof HttpException) { return self::error( - $e->getMessage() ?: ($responseTitle.' 실패'), + $e->getMessage() ?: '서버 에러', $e->getStatusCode(), ['details' => config('app.debug') ? $e->getTraceAsString() : null] ); } - return self::error($responseTitle.' 실패', 500, [ + return self::error('서버 에러', 500, [ 'details' => $e->getMessage(), ]); } diff --git a/app/Helpers/TenantCodeGenerator.php b/app/Helpers/TenantCodeGenerator.php index e5d04eb..9bcaeab 100644 --- a/app/Helpers/TenantCodeGenerator.php +++ b/app/Helpers/TenantCodeGenerator.php @@ -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); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/CategoryController.php b/app/Http/Controllers/Api/V1/CategoryController.php index c2348f0..20de0bb 100644 --- a/app/Http/Controllers/Api/V1/CategoryController.php +++ b/app/Http/Controllers/Api/V1/CategoryController.php @@ -59,4 +59,4 @@ public function tree(Request $request) { return ApiResponse::handle(fn () => $this->service->tree($request->all()), __('message.category.tree_fetched')); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/ClientController.php b/app/Http/Controllers/Api/V1/ClientController.php index e57bba1..308bd48 100644 --- a/app/Http/Controllers/Api/V1/ClientController.php +++ b/app/Http/Controllers/Api/V1/ClientController.php @@ -45,6 +45,7 @@ public function destroy(int $id) { return ApiResponse::handle(function () use ($id) { $this->service->destroy($id); + return 'success'; }, __('message.client.deleted')); } @@ -55,4 +56,4 @@ public function toggle(int $id) return $this->service->toggle($id); }, __('message.client.toggled')); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/FileController.php b/app/Http/Controllers/Api/V1/FileController.php deleted file mode 100644 index 81cd456..0000000 --- a/app/Http/Controllers/Api/V1/FileController.php +++ /dev/null @@ -1,294 +0,0 @@ -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()); - }, '파일 정보 조회'); - } -} diff --git a/app/Http/Controllers/Api/V1/FileStorageController.php b/app/Http/Controllers/Api/V1/FileStorageController.php new file mode 100644 index 0000000..438dabd --- /dev/null +++ b/app/Http/Controllers/Api/V1/FileStorageController.php @@ -0,0 +1,174 @@ +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(); + }); + } +} diff --git a/app/Http/Controllers/Api/V1/FolderController.php b/app/Http/Controllers/Api/V1/FolderController.php new file mode 100644 index 0000000..d47a608 --- /dev/null +++ b/app/Http/Controllers/Api/V1/FolderController.php @@ -0,0 +1,88 @@ +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')); + } +} diff --git a/app/Http/Controllers/Api/V1/MaterialController.php b/app/Http/Controllers/Api/V1/MaterialController.php index 6180be3..cb35793 100644 --- a/app/Http/Controllers/Api/V1/MaterialController.php +++ b/app/Http/Controllers/Api/V1/MaterialController.php @@ -47,4 +47,4 @@ public function destroy(int $id) return $this->service->destroyMaterial($id); }, __('message.material.deleted')); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/RefreshController.php b/app/Http/Controllers/Api/V1/RefreshController.php index 8a8a46a..7b04f79 100644 --- a/app/Http/Controllers/Api/V1/RefreshController.php +++ b/app/Http/Controllers/Api/V1/RefreshController.php @@ -11,9 +11,6 @@ class RefreshController extends Controller { /** * 리프레시 토큰으로 새로운 액세스 토큰을 발급합니다. - * - * @param RefreshRequest $request - * @return JsonResponse */ public function refresh(RefreshRequest $request): JsonResponse { @@ -38,4 +35,4 @@ public function refresh(RefreshRequest $request): JsonResponse 'expires_at' => $tokens['expires_at'], ]); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/TenantController.php b/app/Http/Controllers/Api/V1/TenantController.php index 7cf2695..8d48e8c 100644 --- a/app/Http/Controllers/Api/V1/TenantController.php +++ b/app/Http/Controllers/Api/V1/TenantController.php @@ -54,4 +54,4 @@ public function restore(Request $request) return $this->service->restoreTenant($request->all()); }, __('message.tenant.restored')); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php index 9b8fc73..7c77021 100644 --- a/app/Http/Controllers/Api/V1/UserController.php +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -62,4 +62,4 @@ public function switchTenant(SwitchTenantRequest $request) return $this->service->switchMyTenant($request->validated()['tenant_id']); }, __('message.user.tenant_switched')); } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Api/V1/FileMoveRequest.php b/app/Http/Requests/Api/V1/FileMoveRequest.php new file mode 100644 index 0000000..bedf9e9 --- /dev/null +++ b/app/Http/Requests/Api/V1/FileMoveRequest.php @@ -0,0 +1,35 @@ + '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'), + ]; + } +} diff --git a/app/Http/Requests/Api/V1/FileUploadRequest.php b/app/Http/Requests/Api/V1/FileUploadRequest.php new file mode 100644 index 0000000..130973a --- /dev/null +++ b/app/Http/Requests/Api/V1/FileUploadRequest.php @@ -0,0 +1,39 @@ + [ + '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'), + ]; + } +} diff --git a/app/Http/Requests/Api/V1/FolderStoreRequest.php b/app/Http/Requests/Api/V1/FolderStoreRequest.php new file mode 100644 index 0000000..3fc579b --- /dev/null +++ b/app/Http/Requests/Api/V1/FolderStoreRequest.php @@ -0,0 +1,46 @@ + [ + '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'), + ]; + } +} diff --git a/app/Http/Requests/Api/V1/FolderUpdateRequest.php b/app/Http/Requests/Api/V1/FolderUpdateRequest.php new file mode 100644 index 0000000..1de8352 --- /dev/null +++ b/app/Http/Requests/Api/V1/FolderUpdateRequest.php @@ -0,0 +1,47 @@ +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'), + ]; + } +} diff --git a/app/Http/Requests/Api/V1/RefreshRequest.php b/app/Http/Requests/Api/V1/RefreshRequest.php index 23e54e9..4b0faca 100644 --- a/app/Http/Requests/Api/V1/RefreshRequest.php +++ b/app/Http/Requests/Api/V1/RefreshRequest.php @@ -34,4 +34,4 @@ public function messages(): array 'refresh_token.string' => __('error.refresh_token_invalid'), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Api/V1/ShareLinkRequest.php b/app/Http/Requests/Api/V1/ShareLinkRequest.php new file mode 100644 index 0000000..4906c8f --- /dev/null +++ b/app/Http/Requests/Api/V1/ShareLinkRequest.php @@ -0,0 +1,41 @@ +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'), + ]; + } +} diff --git a/app/Http/Requests/Category/CategoryMoveRequest.php b/app/Http/Requests/Category/CategoryMoveRequest.php index f888fc8..18c809e 100644 --- a/app/Http/Requests/Category/CategoryMoveRequest.php +++ b/app/Http/Requests/Category/CategoryMoveRequest.php @@ -18,4 +18,4 @@ public function rules(): array 'sort_order' => 'nullable|integer', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Category/CategoryReorderRequest.php b/app/Http/Requests/Category/CategoryReorderRequest.php index 886e730..6268c47 100644 --- a/app/Http/Requests/Category/CategoryReorderRequest.php +++ b/app/Http/Requests/Category/CategoryReorderRequest.php @@ -19,4 +19,4 @@ public function rules(): array 'items.*.sort_order' => 'required|integer', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Category/CategoryStoreRequest.php b/app/Http/Requests/Category/CategoryStoreRequest.php index 4ff598f..0cb5a29 100644 --- a/app/Http/Requests/Category/CategoryStoreRequest.php +++ b/app/Http/Requests/Category/CategoryStoreRequest.php @@ -22,4 +22,4 @@ public function rules(): array 'sort_order' => 'nullable|integer', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Category/CategoryUpdateRequest.php b/app/Http/Requests/Category/CategoryUpdateRequest.php index 4e82fec..1add6d9 100644 --- a/app/Http/Requests/Category/CategoryUpdateRequest.php +++ b/app/Http/Requests/Category/CategoryUpdateRequest.php @@ -22,4 +22,4 @@ public function rules(): array 'sort_order' => 'nullable|integer', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Client/ClientStoreRequest.php b/app/Http/Requests/Client/ClientStoreRequest.php index 35d1ebc..04c7770 100644 --- a/app/Http/Requests/Client/ClientStoreRequest.php +++ b/app/Http/Requests/Client/ClientStoreRequest.php @@ -24,4 +24,4 @@ public function rules(): array 'is_active' => 'nullable|in:Y,N', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Client/ClientUpdateRequest.php b/app/Http/Requests/Client/ClientUpdateRequest.php index caf5657..3b48db7 100644 --- a/app/Http/Requests/Client/ClientUpdateRequest.php +++ b/app/Http/Requests/Client/ClientUpdateRequest.php @@ -24,4 +24,4 @@ public function rules(): array 'is_active' => 'nullable|in:Y,N', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Material/MaterialStoreRequest.php b/app/Http/Requests/Material/MaterialStoreRequest.php index a127f69..fd4c77f 100644 --- a/app/Http/Requests/Material/MaterialStoreRequest.php +++ b/app/Http/Requests/Material/MaterialStoreRequest.php @@ -29,4 +29,4 @@ public function rules(): array 'specification' => 'nullable|string|max:255', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Material/MaterialUpdateRequest.php b/app/Http/Requests/Material/MaterialUpdateRequest.php index 571e92a..7c9eee4 100644 --- a/app/Http/Requests/Material/MaterialUpdateRequest.php +++ b/app/Http/Requests/Material/MaterialUpdateRequest.php @@ -29,4 +29,4 @@ public function rules(): array 'specification' => 'nullable|string|max:255', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Product/ProductStoreRequest.php b/app/Http/Requests/Product/ProductStoreRequest.php index 4946f94..c47a4ce 100644 --- a/app/Http/Requests/Product/ProductStoreRequest.php +++ b/app/Http/Requests/Product/ProductStoreRequest.php @@ -26,4 +26,4 @@ public function rules(): array 'is_active' => 'nullable|in:0,1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Product/ProductUpdateRequest.php b/app/Http/Requests/Product/ProductUpdateRequest.php index 6c70a45..7a581d6 100644 --- a/app/Http/Requests/Product/ProductUpdateRequest.php +++ b/app/Http/Requests/Product/ProductUpdateRequest.php @@ -26,4 +26,4 @@ public function rules(): array 'is_active' => 'nullable|in:0,1', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Tenant/TenantStoreRequest.php b/app/Http/Requests/Tenant/TenantStoreRequest.php index 05d42cd..c3a98c4 100644 --- a/app/Http/Requests/Tenant/TenantStoreRequest.php +++ b/app/Http/Requests/Tenant/TenantStoreRequest.php @@ -22,4 +22,4 @@ public function rules(): array 'ceo_name' => 'nullable|string|max:100', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Tenant/TenantUpdateRequest.php b/app/Http/Requests/Tenant/TenantUpdateRequest.php index ce90b13..04c5783 100644 --- a/app/Http/Requests/Tenant/TenantUpdateRequest.php +++ b/app/Http/Requests/Tenant/TenantUpdateRequest.php @@ -23,4 +23,4 @@ public function rules(): array 'ceo_name' => 'nullable|string|max:100', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/User/PasswordChangeRequest.php b/app/Http/Requests/User/PasswordChangeRequest.php index 17e250c..f51c390 100644 --- a/app/Http/Requests/User/PasswordChangeRequest.php +++ b/app/Http/Requests/User/PasswordChangeRequest.php @@ -19,4 +19,4 @@ public function rules(): array 'new_password_confirmation' => 'required|string', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/User/SwitchTenantRequest.php b/app/Http/Requests/User/SwitchTenantRequest.php index 78a0a2d..7a6c845 100644 --- a/app/Http/Requests/User/SwitchTenantRequest.php +++ b/app/Http/Requests/User/SwitchTenantRequest.php @@ -17,4 +17,4 @@ public function rules(): array 'tenant_id' => 'required|integer|exists:tenants,id', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/User/UserUpdateRequest.php b/app/Http/Requests/User/UserUpdateRequest.php index 71f1584..81acee5 100644 --- a/app/Http/Requests/User/UserUpdateRequest.php +++ b/app/Http/Requests/User/UserUpdateRequest.php @@ -19,4 +19,4 @@ public function rules(): array 'email' => 'sometimes|email|max:100', ]; } -} \ No newline at end of file +} diff --git a/app/Models/Commons/File.php b/app/Models/Commons/File.php index 800dbbb..dea6ce1 100644 --- a/app/Models/Commons/File.php +++ b/app/Models/Commons/File.php @@ -2,15 +2,23 @@ namespace App\Models\Commons; +use App\Models\FileShareLink; +use App\Models\Folder; use App\Models\Members\User; +use App\Models\Tenants\Tenant; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Support\Facades\Storage; /** * @mixin IdeHelperFile */ class File extends Model { + use \App\Traits\BelongsToTenant; + use \App\Traits\ModelTrait; use SoftDeletes; protected $table = 'files'; @@ -18,19 +26,71 @@ class File extends Model protected $fillable = [ 'tenant_id', 'file_path', + // Old fields (legacy support) 'original_name', 'file_name', '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', 'mime_type', 'description', - 'fileable_id', - 'fileable_type', '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() { @@ -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); } } diff --git a/app/Models/FileShareLink.php b/app/Models/FileShareLink.php new file mode 100644 index 0000000..dfee50a --- /dev/null +++ b/app/Models/FileShareLink.php @@ -0,0 +1,122 @@ + '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'); + }); + } +} diff --git a/app/Models/Folder.php b/app/Models/Folder.php new file mode 100644 index 0000000..1005138 --- /dev/null +++ b/app/Models/Folder.php @@ -0,0 +1,63 @@ + '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'); + } +} diff --git a/app/Models/Tenants/Tenant.php b/app/Models/Tenants/Tenant.php index a035dd8..282d41f 100644 --- a/app/Models/Tenants/Tenant.php +++ b/app/Models/Tenants/Tenant.php @@ -119,4 +119,129 @@ public function files() { 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]; + } } diff --git a/app/Observers/TenantObserver.php b/app/Observers/TenantObserver.php index 3b1b5a3..ac99c0f 100644 --- a/app/Observers/TenantObserver.php +++ b/app/Observers/TenantObserver.php @@ -2,13 +2,82 @@ namespace App\Observers; +use App\Models\Folder; use App\Models\Tenants\Tenant; use App\Services\TenantBootstrapper; +use Illuminate\Support\Facades\Log; class TenantObserver { public function created(Tenant $tenant): void { + // 테넌트 부트스트랩 실행 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(), + ]); + } } } diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php index 4b4fde3..8245804 100644 --- a/app/Services/AuthService.php +++ b/app/Services/AuthService.php @@ -3,7 +3,6 @@ namespace App\Services; use App\Models\Members\User; -use Carbon\Carbon; use Illuminate\Support\Facades\Config; class AuthService @@ -98,7 +97,6 @@ public static function refreshTokens(string $refreshToken): ?array * 사용자의 모든 토큰을 삭제합니다 (로그아웃). * * @param User $user 사용자 모델 - * @return void */ public static function revokeAllTokens(User $user): void { @@ -110,10 +108,9 @@ public static function revokeAllTokens(User $user): void * * @param User $user 사용자 모델 * @param string $tokenId 토큰 ID - * @return void */ public static function revokeToken(User $user, string $tokenId): void { $user->tokens()->where('id', $tokenId)->delete(); } -} \ No newline at end of file +} diff --git a/app/Services/FileStorageService.php b/app/Services/FileStorageService.php new file mode 100644 index 0000000..73df813 --- /dev/null +++ b/app/Services/FileStorageService.php @@ -0,0 +1,351 @@ +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]; + } +} diff --git a/app/Services/FolderService.php b/app/Services/FolderService.php new file mode 100644 index 0000000..c5581e6 --- /dev/null +++ b/app/Services/FolderService.php @@ -0,0 +1,109 @@ +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; + } +} diff --git a/app/Services/MenuBootstrapService.php b/app/Services/MenuBootstrapService.php index f987bc0..6ecafa0 100644 --- a/app/Services/MenuBootstrapService.php +++ b/app/Services/MenuBootstrapService.php @@ -150,4 +150,4 @@ public static function createDefaultMenus(int $tenantId): array return $menuIds; }); } -} \ No newline at end of file +} diff --git a/app/Swagger/v1/FileApi.php b/app/Swagger/v1/FileApi.php index cff078d..e2584f0 100644 --- a/app/Swagger/v1/FileApi.php +++ b/app/Swagger/v1/FileApi.php @@ -2,4 +2,529 @@ 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() {} +} diff --git a/app/Swagger/v1/FolderApi.php b/app/Swagger/v1/FolderApi.php new file mode 100644 index 0000000..6966cc5 --- /dev/null +++ b/app/Swagger/v1/FolderApi.php @@ -0,0 +1,324 @@ + false, ], + 'tenant' => [ + 'driver' => 'local', + 'root' => storage_path('app/tenants'), + 'visibility' => 'private', + 'throw' => false, + 'report' => false, + ], + 's3' => [ 'driver' => 's3', '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 diff --git a/database/migrations/2025_11_10_190208_enhance_files_table.php b/database/migrations/2025_11_10_190208_enhance_files_table.php new file mode 100644 index 0000000..c0ca03b --- /dev/null +++ b/database/migrations/2025_11_10_190208_enhance_files_table.php @@ -0,0 +1,162 @@ + '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); + } + }); + } +}; diff --git a/database/migrations/2025_11_10_190257_create_folders_table.php b/database/migrations/2025_11_10_190257_create_folders_table.php new file mode 100644 index 0000000..5198965 --- /dev/null +++ b/database/migrations/2025_11_10_190257_create_folders_table.php @@ -0,0 +1,53 @@ +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'); + } +}; diff --git a/database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php b/database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php new file mode 100644 index 0000000..2adf715 --- /dev/null +++ b/database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php @@ -0,0 +1,41 @@ +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', + ]); + }); + } +}; diff --git a/database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php b/database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php new file mode 100644 index 0000000..8e6a508 --- /dev/null +++ b/database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php @@ -0,0 +1,52 @@ +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'); + } +}; diff --git a/database/migrations/2025_11_10_190355_create_file_share_links_table.php b/database/migrations/2025_11_10_190355_create_file_share_links_table.php new file mode 100644 index 0000000..caa78b6 --- /dev/null +++ b/database/migrations/2025_11_10_190355_create_file_share_links_table.php @@ -0,0 +1,52 @@ +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'); + } +}; diff --git a/database/migrations/2025_11_10_190355_create_storage_usage_history_table.php b/database/migrations/2025_11_10_190355_create_storage_usage_history_table.php new file mode 100644 index 0000000..5c93269 --- /dev/null +++ b/database/migrations/2025_11_10_190355_create_storage_usage_history_table.php @@ -0,0 +1,43 @@ +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'); + } +}; diff --git a/database/seeders/FolderSeeder.php b/database/seeders/FolderSeeder.php new file mode 100644 index 0000000..4d320e6 --- /dev/null +++ b/database/seeders/FolderSeeder.php @@ -0,0 +1,91 @@ +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}"); + } + } +} diff --git a/lang/ko/error.php b/lang/ko/error.php index 9875977..d831563 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -77,6 +77,26 @@ '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 기준 매출단가를 찾을 수 없습니다.', diff --git a/lang/ko/message.php b/lang/ko/message.php index 1ef2fd9..21ece4c 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -134,4 +134,19 @@ 'deleted' => '파일이 삭제되었습니다.', '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' => '폴더 순서가 변경되었습니다.', ]; diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 8d198bc..546f696 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -184,4 +184,4 @@ 'tenant_id' => '테넌트', ], -]; \ No newline at end of file +]; diff --git a/routes/api.php b/routes/api.php index 5d0f487..ef828b6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -2,7 +2,6 @@ use App\Http\Controllers\Api\V1\AdminController; 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\CategoryFieldController; 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\ModelVersionController as DesignModelVersionController; 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\MenuController; use App\Http\Controllers\Api\V1\ModelSetController; @@ -26,6 +26,7 @@ use App\Http\Controllers\Api\V1\PricingController; use App\Http\Controllers\Api\V1\ProductBomItemController; 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\RoleController; use App\Http\Controllers\Api\V1\RolePermissionController; @@ -104,14 +105,6 @@ 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 Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () { 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'); // 견적 계산 미리보기 }); + // 파일 저장소 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'); }); diff --git a/routes/console.php b/routes/console.php index 4fa4eb9..de9add0 100644 --- a/routes/console.php +++ b/routes/console.php @@ -30,3 +30,47 @@ ->onFailure(function () { \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()]); + });