Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ out/

# Ignore macOS system files
.DS_Store
**/.DS_Store/**
**/.DS_Store/**

# File uploads
uploads/
42 changes: 42 additions & 0 deletions src/main/java/com/demo/pteam/global/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -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시간 설정
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand Down Expand Up @@ -88,4 +90,20 @@ public ResponseEntity<ApiResponse<Void>> deleteReview(
ApiResponse<Void> apiResponse = ApiResponse.success("리뷰가 성공적으로 삭제되었습니다.", null);
return ResponseEntity.ok(apiResponse);
}

@PostMapping("/images")
public ResponseEntity<ApiResponse<ReviewImageUploadResponseDto>> 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<ReviewImageUploadResponseDto> apiResponse = ApiResponse.success("이미지가 성공적으로 업로드되었습니다.", responseDto);
return ResponseEntity.status(HttpStatus.CREATED).body(apiResponse);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -45,4 +48,8 @@ public ReviewImageEntity updateReview(ReviewEntity review) {
this.review = review;
return this;
}

public void updateDisplayOrder(Byte displayOrder) {
this.displayOrder = displayOrder;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
84 changes: 84 additions & 0 deletions src/main/java/com/demo/pteam/review/service/ReviewService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -35,6 +39,7 @@ public class ReviewService {
// private final AccountRepository accountRepository;
private final ScheduleRepository scheduleRepository;
private final ReviewMapper reviewMapper;
private final FileStorageService fileStorageService;

/**
* 리뷰 생성
Expand Down Expand Up @@ -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<String> allowedExtensions = Arrays.asList("jpg", "jpeg", "png", "gif");
return allowedExtensions.contains(extension.toLowerCase());
}

// 이미지 연결
private List<ReviewImageEntity> connectImagesToReview(List<Long> imageIds, ReviewEntity review) {
Expand All @@ -187,13 +261,23 @@ private List<ReviewImageEntity> connectImagesToReview(List<Long> 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);
}

image.updateReview(review);

// displayOrder 설정 (순서대로 1, 2, 3...)
int orderIndex = imageIds.indexOf(imageId) + 1;
image.updateDisplayOrder((byte) orderIndex);

return reviewImageRepository.save(image);
})
.collect(Collectors.toList());
Expand Down
11 changes: 10 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
org.springframework: info
org.springframework.web.multipart: DEBUG

file:
upload-dir: ./uploads