feat: 품목 파일 업로드를 files 테이블 기반으로 변경
- 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 규격 준수
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user