From cc3feb1927d3f6950e28c39c74981947b61d8439 Mon Sep 17 00:00:00 2001 From: hskwon Date: Thu, 11 Dec 2025 22:40:55 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=92=88=EB=AA=A9=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20files=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ItemsFileController: files 테이블에 메타데이터 저장, products는 file_id 참조 - 저장 경로: storage/app/tenants/{tenant_id}/items/{year}/{month}/{stored_name} - 파일명: 64bit 난수로 보안 강화 (bin2hex(random_bytes(8))) - Swagger 문서: file_id 반환 및 저장 구조 설명 추가 - file-storage-guide.md 규격 준수 --- .../Api/V1/ItemsFileController.php | 141 +++++++++++++----- app/Swagger/v1/ItemsFileApi.php | 43 +++--- 2 files changed, 132 insertions(+), 52 deletions(-) diff --git a/app/Http/Controllers/Api/V1/ItemsFileController.php b/app/Http/Controllers/Api/V1/ItemsFileController.php index d9f38de..3de3521 100644 --- a/app/Http/Controllers/Api/V1/ItemsFileController.php +++ b/app/Http/Controllers/Api/V1/ItemsFileController.php @@ -2,9 +2,10 @@ namespace App\Http\Controllers\Api\V1; +use App\Helpers\ApiResponse; use App\Http\Controllers\Controller; use App\Http\Requests\ItemsFileUploadRequest; -use App\Http\Responses\ApiResponse; +use App\Models\Commons\File; use App\Models\Products\Product; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -14,7 +15,7 @@ /** * 품목 파일 관리 컨트롤러 * - * ID-based 파일 업로드/삭제 API + * ID-based 파일 업로드/삭제 API (files 테이블 기반) * - 절곡도 (bending_diagram) * - 시방서 (specification) * - 인정서 (certification) @@ -25,30 +26,67 @@ class ItemsFileController extends Controller * 파일 업로드 * * POST /api/v1/items/{id}/files + * + * 파일 저장 구조 (file-storage-guide.md 참고): + * - 물리 경로: storage/app/tenants/{tenant_id}/items/{year}/{month}/{stored_name} + * - DB: files 테이블에 저장 + * - Product: file_id 참조 */ public function upload(int $id, ItemsFileUploadRequest $request) { return ApiResponse::handle(function () use ($id, $request) { + $tenantId = app('tenant_id'); + $userId = auth()->id(); $product = $this->getProductById($id); $validated = $request->validated(); - $fileType = $request->route('type') ?? $validated['type']; - $file = $validated['file']; + $fileType = $validated['type']; + $uploadedFile = $validated['file']; - // 파일 저장 경로: items/{id}/{type}/{filename} - $directory = sprintf('items/%d/%s', $id, $fileType); - $filePath = Storage::disk('public')->putFile($directory, $file); - $fileUrl = Storage::disk('public')->url($filePath); - $originalName = $file->getClientOriginalName(); + // 파일명 생성 (64bit 난수) + $extension = $uploadedFile->getClientOriginalExtension(); + $storedName = bin2hex(random_bytes(8)) . '.' . $extension; + $displayName = $uploadedFile->getClientOriginalName(); - // Product 모델 업데이트 - $updateData = $this->buildUpdateData($fileType, $filePath, $originalName, $validated); + // 경로 생성: tenants/{tenant_id}/items/{year}/{month}/{stored_name} + $year = date('Y'); + $month = date('m'); + $directory = sprintf('%d/items/%s/%s', $tenantId, $year, $month); + $filePath = $directory . '/' . $storedName; + + // 파일 저장 (tenant 디스크) + Storage::disk('tenant')->putFileAs($directory, $uploadedFile, $storedName); + + // files 테이블에 저장 + $file = File::create([ + 'tenant_id' => $tenantId, + 'display_name' => $displayName, + 'stored_name' => $storedName, + 'file_path' => $filePath, + 'file_size' => $uploadedFile->getSize(), + 'mime_type' => $uploadedFile->getMimeType(), + 'file_type' => $this->getFileTypeCategory($uploadedFile->getMimeType()), + 'document_id' => $product->id, + 'document_type' => 'product_' . $fileType, // product_bending_diagram, product_specification, product_certification + 'is_temp' => false, + 'uploaded_by' => $userId, + 'created_by' => $userId, + ]); + + // Product 모델 업데이트 (file_id 참조 방식) + $updateData = $this->buildUpdateData($fileType, $file->id, $validated); $product->update($updateData); + // 파일 URL 생성 + $fileUrl = $this->getFileUrl($filePath); + return [ + 'file_id' => $file->id, 'file_type' => $fileType, 'file_url' => $fileUrl, 'file_path' => $filePath, - 'file_name' => $originalName, + 'file_name' => $displayName, + 'file_size' => $file->file_size, + 'mime_type' => $file->mime_type, 'product' => $product->fresh(), ]; }, __('message.file.uploaded')); @@ -62,6 +100,7 @@ public function upload(int $id, ItemsFileUploadRequest $request) public function delete(int $id, string $type, Request $request) { return ApiResponse::handle(function () use ($id, $type) { + $userId = auth()->id(); $product = $this->getProductById($id); // 파일 타입 검증 @@ -69,21 +108,26 @@ public function delete(int $id, string $type, Request $request) throw new \InvalidArgumentException(__('error.file.invalid_file_type')); } - // 파일 경로 가져오기 - $filePath = $this->getFilePath($product, $type); + // 파일 ID 가져오기 + $fileId = $this->getFileId($product, $type); + $deleted = false; - if ($filePath) { - // 물리적 파일 삭제 - Storage::disk('public')->delete($filePath); + if ($fileId) { + $file = File::find($fileId); + if ($file) { + // Soft delete (files 테이블) + $file->softDeleteFile($userId); + $deleted = true; + } } - // DB 필드 null 처리 + // DB 필드 null 처리 (Product) $updateData = $this->buildDeleteData($type); $product->update($updateData); return [ 'file_type' => $type, - 'deleted' => (bool) $filePath, + 'deleted' => $deleted, 'product' => $product->fresh(), ]; }, __('message.file.deleted')); @@ -107,30 +151,57 @@ private function getProductById(int $id): Product } /** - * 파일 타입별 업데이트 데이터 구성 + * MIME 타입에서 파일 타입 카테고리 추출 */ - private function buildUpdateData(string $fileType, string $filePath, string $originalName, array $validated): array + private function getFileTypeCategory(string $mimeType): string { - $updateData = match ($fileType) { + 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'; + } + + /** + * 파일 URL 생성 (tenant 디스크는 private이므로 API 경유) + */ + private function getFileUrl(string $filePath): string + { + // Public URL이 아닌 API 다운로드 경로 반환 + return url('/api/v1/files/download/' . base64_encode($filePath)); + } + + /** + * 파일 타입별 업데이트 데이터 구성 (file_id 참조) + */ + private function buildUpdateData(string $fileType, int $fileId, array $validated): array + { + return match ($fileType) { 'bending_diagram' => [ - 'bending_diagram' => $filePath, + 'bending_diagram' => $fileId, // file_id 저장 'bending_details' => $validated['bending_details'] ?? null, ], 'specification' => [ - 'specification_file' => $filePath, - 'specification_file_name' => $originalName, + 'specification_file' => $fileId, // file_id 저장 ], 'certification' => [ - 'certification_file' => $filePath, - 'certification_file_name' => $originalName, + 'certification_file' => $fileId, // file_id 저장 'certification_number' => $validated['certification_number'] ?? null, 'certification_start_date' => $validated['certification_start_date'] ?? null, 'certification_end_date' => $validated['certification_end_date'] ?? null, ], default => throw new \InvalidArgumentException(__('error.file.invalid_file_type')), }; - - return $updateData; } /** @@ -145,11 +216,9 @@ private function buildDeleteData(string $fileType): array ], 'specification' => [ 'specification_file' => null, - 'specification_file_name' => null, ], 'certification' => [ 'certification_file' => null, - 'certification_file_name' => null, 'certification_number' => null, 'certification_start_date' => null, 'certification_end_date' => null, @@ -159,15 +228,17 @@ private function buildDeleteData(string $fileType): array } /** - * Product에서 파일 경로 가져오기 + * Product에서 파일 ID 가져오기 */ - private function getFilePath(Product $product, string $fileType): ?string + private function getFileId(Product $product, string $fileType): ?int { - return match ($fileType) { + $value = match ($fileType) { 'bending_diagram' => $product->bending_diagram, 'specification' => $product->specification_file, 'certification' => $product->certification_file, default => null, }; + + return $value ? (int) $value : null; } -} +} \ No newline at end of file diff --git a/app/Swagger/v1/ItemsFileApi.php b/app/Swagger/v1/ItemsFileApi.php index 6be70ab..ab4f890 100644 --- a/app/Swagger/v1/ItemsFileApi.php +++ b/app/Swagger/v1/ItemsFileApi.php @@ -5,18 +5,21 @@ /** * @OA\Tag( * name="Items Files", - * description="품목 파일 관리 API (절곡도, 시방서, 인정서)" + * description="품목 파일 관리 API (절곡도, 시방서, 인정서) - files 테이블 기반" * ) * * @OA\Schema( * schema="ItemFileUploadResponse", * type="object", - * required={"file_type", "file_url", "file_path", "file_name"}, + * required={"file_id", "file_type", "file_url", "file_path", "file_name"}, * + * @OA\Property(property="file_id", type="integer", example=123, description="files 테이블 ID"), * @OA\Property(property="file_type", type="string", enum={"bending_diagram", "specification", "certification"}, example="bending_diagram", description="파일 타입"), - * @OA\Property(property="file_url", type="string", format="uri", example="http://api.sam.kr/storage/items/P-001/bending_diagram/abc123.jpg", description="파일 URL"), - * @OA\Property(property="file_path", type="string", example="items/P-001/bending_diagram/abc123.jpg", description="파일 경로"), + * @OA\Property(property="file_url", type="string", format="uri", example="https://api.sam.kr/api/v1/files/download/MjE3L2l0ZW1zLzIwMjUvMTIvYTFiMmMzZDRlNWY2ZzdoOC5wbmc=", description="파일 다운로드 URL"), + * @OA\Property(property="file_path", type="string", example="287/items/2025/12/a1b2c3d4e5f6g7h8.png", description="파일 저장 경로"), * @OA\Property(property="file_name", type="string", example="절곡도_V1.jpg", description="원본 파일명"), + * @OA\Property(property="file_size", type="integer", example=102400, description="파일 크기 (bytes)"), + * @OA\Property(property="mime_type", type="string", example="image/png", description="MIME 타입"), * @OA\Property(property="product", ref="#/components/schemas/Product", description="업데이트된 품목 정보") * ) * @@ -26,7 +29,7 @@ * required={"file_type", "deleted"}, * * @OA\Property(property="file_type", type="string", enum={"bending_diagram", "specification", "certification"}, example="bending_diagram", description="파일 타입"), - * @OA\Property(property="deleted", type="boolean", example=true, description="파일 삭제 여부"), + * @OA\Property(property="deleted", type="boolean", example=true, description="파일 삭제 여부 (soft delete)"), * @OA\Property(property="product", ref="#/components/schemas/Product", description="업데이트된 품목 정보") * ) */ @@ -36,20 +39,26 @@ class ItemsFileApi * 파일 업로드 * * @OA\Post( - * path="/api/v1/items/{code}/files", + * path="/api/v1/items/{id}/files", * summary="품목 파일 업로드", - * description="품목에 파일을 업로드합니다 (절곡도/시방서/인정서)", + * description="품목에 파일을 업로드합니다 (절곡도/시방서/인정서). 파일은 files 테이블에 저장되고, Product에는 file_id가 참조됩니다. + * + * **저장 경로**: `storage/app/tenants/{tenant_id}/items/{year}/{month}/{stored_name}` + * + * **저장 구조**: + * - files 테이블: 파일 메타데이터 저장 (display_name, stored_name, file_path, file_size, mime_type 등) + * - products 테이블: file_id 참조 (bending_diagram, specification_file, certification_file 컬럼)", * operationId="uploadItemFile", * tags={"Items Files"}, * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\Parameter( - * name="code", + * name="id", * in="path", * required=true, - * description="품목 코드", + * description="품목 ID", * - * @OA\Schema(type="string", example="P-001") + * @OA\Schema(type="integer", example=795) * ), * * @OA\RequestBody( @@ -72,7 +81,7 @@ class ItemsFileApi * property="file", * type="string", * format="binary", - * description="업로드할 파일 (절곡도: jpg,png,gif,svg / 문서: pdf,doc,docx,xls,xlsx,hwp)" + * description="업로드할 파일 (이미지: jpg,png,gif,svg / 문서: pdf,doc,docx,xls,xlsx,hwp). 최대 20MB" * ), * @OA\Property( * property="bending_details", @@ -135,20 +144,20 @@ public function upload() {} * 파일 삭제 * * @OA\Delete( - * path="/api/v1/items/{code}/files/{type}", + * path="/api/v1/items/{id}/files/{type}", * summary="품목 파일 삭제", - * description="품목의 파일을 삭제합니다", + * description="품목의 파일을 삭제합니다 (Soft Delete). files 테이블의 deleted_at이 설정되고, products 테이블의 file_id 참조가 null로 변경됩니다.", * operationId="deleteItemFile", * tags={"Items Files"}, * security={{"ApiKeyAuth": {}, "BearerAuth": {}}}, * * @OA\Parameter( - * name="code", + * name="id", * in="path", * required=true, - * description="품목 코드", + * description="품목 ID", * - * @OA\Schema(type="string", example="P-001") + * @OA\Schema(type="integer", example=795) * ), * * @OA\Parameter( @@ -177,4 +186,4 @@ public function upload() {} * ) */ public function delete() {} -} +} \ No newline at end of file