diff --git a/.gitignore b/.gitignore index 6b07b948..6847fce4 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ out/ # Ignore macOS system files .DS_Store -**/.DS_Store/** \ No newline at end of file +**/.DS_Store/** + +# File uploads +uploads/ diff --git a/src/main/java/com/demo/pteam/global/config/WebConfig.java b/src/main/java/com/demo/pteam/global/config/WebConfig.java new file mode 100644 index 00000000..423115cd --- /dev/null +++ b/src/main/java/com/demo/pteam/global/config/WebConfig.java @@ -0,0 +1,42 @@ +package com.demo.pteam.global.config; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +@Slf4j +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${file.upload-dir:./uploads}") + private String uploadDir; + + @PostConstruct + public void init() { + try { + Path uploadPath = Paths.get(uploadDir).toAbsolutePath(); + Files.createDirectories(uploadPath); + log.info("Upload directory initialized: {}", uploadPath); + } catch (IOException e) { + log.error("Failed to create upload directory: {}", uploadDir, e); + throw new RuntimeException("Upload directory initialization failed", e); + } + } + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String absolutePath = Paths.get(uploadDir).toAbsolutePath().toString(); + + registry.addResourceHandler("/uploads/**") // URL 패턴: /uploads/파일명 + .addResourceLocations("file:" + absolutePath + "/") // 실제 파일 위치 + .setCachePeriod(3600); // 브라우저 캐시 1시간 설정 + } +} diff --git a/src/main/java/com/demo/pteam/review/controller/ReviewController.java b/src/main/java/com/demo/pteam/review/controller/ReviewController.java index 0ce1d7b0..a0a70f0d 100644 --- a/src/main/java/com/demo/pteam/review/controller/ReviewController.java +++ b/src/main/java/com/demo/pteam/review/controller/ReviewController.java @@ -2,6 +2,7 @@ import com.demo.pteam.global.response.ApiResponse; import com.demo.pteam.review.controller.dto.ReviewCreateRequestDto; +import com.demo.pteam.review.controller.dto.ReviewImageUploadResponseDto; import com.demo.pteam.review.controller.dto.ReviewResponseDto; import com.demo.pteam.review.controller.dto.ReviewUpdateRequestDto; import com.demo.pteam.review.service.ReviewService; @@ -12,6 +13,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/reviews") @@ -88,4 +90,20 @@ public ResponseEntity> deleteReview( ApiResponse apiResponse = ApiResponse.success("리뷰가 성공적으로 삭제되었습니다.", null); return ResponseEntity.ok(apiResponse); } + + @PostMapping("/images") + public ResponseEntity> uploadReviewImage( + @RequestParam("file") MultipartFile file, + @AuthenticationPrincipal UserDetails userDetails) { + + // TODO + // 현재 인증된 사용자 ID 가져오기 + // Long userId = ((CustomUserDetails) userDetails).getId(); + Long userId = null; + + ReviewImageUploadResponseDto responseDto = reviewService.uploadReviewImage(file, userId); + + ApiResponse apiResponse = ApiResponse.success("이미지가 성공적으로 업로드되었습니다.", responseDto); + return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse); + } } diff --git a/src/main/java/com/demo/pteam/review/controller/dto/ReviewImageUploadResponseDto.java b/src/main/java/com/demo/pteam/review/controller/dto/ReviewImageUploadResponseDto.java new file mode 100644 index 00000000..2f73d98e --- /dev/null +++ b/src/main/java/com/demo/pteam/review/controller/dto/ReviewImageUploadResponseDto.java @@ -0,0 +1,31 @@ +package com.demo.pteam.review.controller.dto; + +import com.demo.pteam.review.repository.entity.ReviewImageEntity; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class ReviewImageUploadResponseDto { + private Long id; + private String imageUrl; + private LocalDateTime createdAt; + + @Builder + public ReviewImageUploadResponseDto(Long id, String imageUrl, LocalDateTime createdAt) { + this.id = id; + this.imageUrl = imageUrl; + this.createdAt = createdAt; + } + + public static ReviewImageUploadResponseDto from(ReviewImageEntity entity) { + return ReviewImageUploadResponseDto.builder() + .id(entity.getId()) + .imageUrl(entity.getImageUrl()) + .createdAt(entity.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/demo/pteam/review/exception/ReviewErrorCode.java b/src/main/java/com/demo/pteam/review/exception/ReviewErrorCode.java index d0dd39a6..88a6cf50 100644 --- a/src/main/java/com/demo/pteam/review/exception/ReviewErrorCode.java +++ b/src/main/java/com/demo/pteam/review/exception/ReviewErrorCode.java @@ -19,7 +19,13 @@ public enum ReviewErrorCode implements ErrorCode { NOT_REVIEW_OWNER(HttpStatus.FORBIDDEN,"R_010", "리뷰 작성자만 수정/삭제할 수 있습니다."), INVALID_CONTENT(HttpStatus.BAD_REQUEST, "R_011", "리뷰 내용이 유효하지 않습니다."), INVALID_RATING(HttpStatus.BAD_REQUEST, "R_012", "별점은 0.0-5.0 사이의 값이어야 합니다."), - REVIEW_EDIT_TIME_EXPIRED(HttpStatus.FORBIDDEN, "R_013", "리뷰 수정은 작성 후 48시간 이내에만 가능합니다."); + REVIEW_EDIT_TIME_EXPIRED(HttpStatus.FORBIDDEN, "R_013", "리뷰 수정은 작성 후 48시간 이내에만 가능합니다."), + IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "R_020", "이미지 파일이 필요합니다."), + INVALID_IMAGE_FORMAT(HttpStatus.BAD_REQUEST, "R_021", "지원하지 않는 이미지 형식입니다."), + IMAGE_TOO_LARGE(HttpStatus.BAD_REQUEST, "R_022", "이미지 크기가 너무 큽니다."), + IMAGE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "R_023", "이미지 업로드에 실패했습니다."), + NOT_IMAGE_OWNER(HttpStatus.FORBIDDEN, "R_024", "이미지 소유자만 삭제할 수 있습니다."), + IMAGE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "R_025", "다른 사용자의 이미지를 사용할 수 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/demo/pteam/review/repository/entity/ReviewImageEntity.java b/src/main/java/com/demo/pteam/review/repository/entity/ReviewImageEntity.java index 583c776b..5cd25756 100644 --- a/src/main/java/com/demo/pteam/review/repository/entity/ReviewImageEntity.java +++ b/src/main/java/com/demo/pteam/review/repository/entity/ReviewImageEntity.java @@ -23,6 +23,9 @@ public class ReviewImageEntity extends BaseEntity { @JoinColumn(name = "review_id", nullable = false) private ReviewEntity review; + @Column(name = "user_id") + private Long userId; + @Column(name = "image_url", nullable = false, length = 255) private String imageUrl; @@ -45,4 +48,8 @@ public ReviewImageEntity updateReview(ReviewEntity review) { this.review = review; return this; } + + public void updateDisplayOrder(Byte displayOrder) { + this.displayOrder = displayOrder; + } } diff --git a/src/main/java/com/demo/pteam/review/service/FileStorageService.java b/src/main/java/com/demo/pteam/review/service/FileStorageService.java new file mode 100644 index 00000000..af5db477 --- /dev/null +++ b/src/main/java/com/demo/pteam/review/service/FileStorageService.java @@ -0,0 +1,10 @@ +package com.demo.pteam.review.service; + +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +public interface FileStorageService { + String storeFile(MultipartFile file) throws IOException; + void deleteFile(String fileUrl) throws IOException; +} diff --git a/src/main/java/com/demo/pteam/review/service/LocalFileStorageService.java b/src/main/java/com/demo/pteam/review/service/LocalFileStorageService.java new file mode 100644 index 00000000..7f44cca0 --- /dev/null +++ b/src/main/java/com/demo/pteam/review/service/LocalFileStorageService.java @@ -0,0 +1,65 @@ +package com.demo.pteam.review.service; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +@Service +public class LocalFileStorageService implements FileStorageService { + + @Value("${file.upload-dir:uploads}") + private String uploadDir; + + @PostConstruct + public void init() { + try { + Path uploadPath = Paths.get(uploadDir); + if (!Files.exists(uploadPath)) { + Files.createDirectories(uploadPath); + } + } catch (IOException e) { + throw new RuntimeException("업로드 디렉토리 생성에 실패했습니다.", e); + } + } + + @Override + public String storeFile(MultipartFile file) throws IOException { + // 파일명 검증 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null || originalFilename.isEmpty()) { + throw new IOException("파일명이 유효하지 않습니다."); + } + + // 고유한 파일명 생성 + String fileName = UUID.randomUUID() + "_" + originalFilename; + Path filePath = Paths.get(uploadDir).resolve(fileName); + + Files.copy(file.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + + // URL 반환 + return "/uploads/" + fileName; + } + + @Override + public void deleteFile(String fileUrl) throws IOException { + if (fileUrl == null || fileUrl.isEmpty()) { + return; + } + + // URL에서 파일명 추출 + String fileName = fileUrl.substring(fileUrl.lastIndexOf("/") + 1); + Path filePath = Paths.get(uploadDir).resolve(fileName); + + // 파일 삭제 + Files.deleteIfExists(filePath); + } +} diff --git a/src/main/java/com/demo/pteam/review/service/ReviewService.java b/src/main/java/com/demo/pteam/review/service/ReviewService.java index 524a3ddd..f32c801a 100644 --- a/src/main/java/com/demo/pteam/review/service/ReviewService.java +++ b/src/main/java/com/demo/pteam/review/service/ReviewService.java @@ -3,6 +3,7 @@ import com.demo.pteam.authentication.repository.entity.AccountEntity; import com.demo.pteam.global.exception.ApiException; import com.demo.pteam.review.controller.dto.ReviewCreateRequestDto; +import com.demo.pteam.review.controller.dto.ReviewImageUploadResponseDto; import com.demo.pteam.review.controller.dto.ReviewResponseDto; import com.demo.pteam.review.controller.dto.ReviewUpdateRequestDto; import com.demo.pteam.review.domain.ReviewDomain; @@ -18,9 +19,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @@ -35,6 +39,7 @@ public class ReviewService { // private final AccountRepository accountRepository; private final ScheduleRepository scheduleRepository; private final ReviewMapper reviewMapper; + private final FileStorageService fileStorageService; /** * 리뷰 생성 @@ -173,8 +178,77 @@ public void deleteReview(Long reviewId, Long userId) { } + /** + * 리뷰 이미지 업로드 + * @param multipartFile 업로드할 이미지 파일 + * @param userId 현재 인증된 사용자 ID + * @return 업로드된 이미지 정보 + */ + public ReviewImageUploadResponseDto uploadReviewImage(MultipartFile multipartFile, Long userId) { + // 파일 유효성 검사 + if (multipartFile == null || multipartFile.isEmpty()) { + throw new ApiException(ReviewErrorCode.IMAGE_REQUIRED); + } + + // 파일 확장자 검사 + String originalFilename = multipartFile.getOriginalFilename(); + String extension = getExtension(originalFilename); + if (!isValidImageExtension(extension)) { + throw new ApiException(ReviewErrorCode.INVALID_IMAGE_FORMAT); + } + + // 파일 타입 검증 + if (!isValidImageType(multipartFile.getContentType())) { + throw new ApiException(ReviewErrorCode.INVALID_IMAGE_FORMAT); + } + + // 파일 크기 검사 (5MB 제한) + if (multipartFile.getSize() > 5 * 1024 * 1024) { + throw new ApiException(ReviewErrorCode.IMAGE_TOO_LARGE); + } + + try { + String imageUrl = fileStorageService.storeFile(multipartFile); + + ReviewImageEntity imageEntity = ReviewImageEntity.builder() + .userId(userId) + .imageUrl(imageUrl) + .fileName(originalFilename) + .fileType(multipartFile.getContentType()) + .fileSize((int) multipartFile.getSize()) + .isActive(true) + // review는 null (나중에 리뷰 생성 시 연결) + // displayOrder는 null (리뷰 연결 시 설정) + .build(); + ReviewImageEntity savedImage = reviewImageRepository.save(imageEntity); + + return ReviewImageUploadResponseDto.from(savedImage); + } catch (IOException e) { + throw new ApiException(ReviewErrorCode.IMAGE_UPLOAD_FAIL); + } + } + + // 메서드 + // 파일 타입 검증 + private boolean isValidImageType(String contentType) { + return contentType != null && contentType.startsWith("image/"); + } + + // 파일 확장자 추출 + private String getExtension(String filename) { + if (filename == null) return ""; + int lastDotIndex = filename.lastIndexOf("."); + if (lastDotIndex == -1) return ""; + return filename.substring(lastDotIndex + 1).toLowerCase(); + } + + // 이미지 확장자 검증 + private boolean isValidImageExtension(String extension) { + List allowedExtensions = Arrays.asList("jpg", "jpeg", "png", "gif"); + return allowedExtensions.contains(extension.toLowerCase()); + } // 이미지 연결 private List connectImagesToReview(List imageIds, ReviewEntity review) { @@ -187,6 +261,12 @@ private List connectImagesToReview(List imageIds, Revie ReviewImageEntity image = reviewImageRepository.findById(imageId) .orElseThrow(() -> new ApiException(ReviewErrorCode.IMAGE_NOT_FOUND)); + + // 권한 체크 + if (!image.getUserId().equals(review.getUser().getId())) { + throw new ApiException(ReviewErrorCode.IMAGE_ACCESS_DENIED); + } + // 이미지가 이미 리뷰와 연결되어 있는지 확인 if (image.getReview() != null && !image.getReview().equals(review)) { throw new ApiException(ReviewErrorCode.IMAGE_ALREADY_LINKED); @@ -194,6 +274,10 @@ private List connectImagesToReview(List imageIds, Revie image.updateReview(review); + // displayOrder 설정 (순서대로 1, 2, 3...) + int orderIndex = imageIds.indexOf(imageId) + 1; + image.updateDisplayOrder((byte) orderIndex); + return reviewImageRepository.save(image); }) .collect(Collectors.toList()); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a76d8beb..9e0795a7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,15 @@ spring: out-of-order: true locations: classpath:db/migration, classpath:db/data + servlet: + multipart: + max-file-size: 5MB + max-request-size: 10MB + logging: level: - org.springframework: info \ No newline at end of file + org.springframework: info + org.springframework.web.multipart: DEBUG + +file: + upload-dir: ./uploads