Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] 파일 S3 업로드 추가, 필터에서 Multipart 데이터를 받기 위한 ServletRequest Wrapping #64

Merged
merged 9 commits into from
Aug 22, 2024
4 changes: 3 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ repositories {
}

dependencies {
// s3 bucket (img)
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
// thymeleaf
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
// redis
Expand All @@ -54,7 +56,7 @@ dependencies {
// lombok
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
// test h2 database
// h2 database (test)
testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;


@RestController
Expand All @@ -36,14 +37,20 @@ public CommonResponse<EventListResponseDto> getEventList(){

@PutMapping("/arrival/config")
@Operation(summary = "선착순 이벤트 수정 Api")
public CommonResponse<EventDto> configArrivalEvent(@RequestBody @Valid ConfigEventRequestDto dto){
return new CommonResponse<>(adminService.configArrivalEvent(dto));
public CommonResponse<EventDto> configArrivalEvent(
@RequestPart(value = "dto") ConfigEventRequestDto dto,
@RequestPart(value = "file", required = false) MultipartFile file
){
return new CommonResponse<>(adminService.configArrivalEvent(dto, file));
}

@PutMapping("/lots/config")
@Operation(summary = "랜덤추첨 이벤트 수정 Api")
public CommonResponse<EventDto> configLotsEvent(@RequestBody @Valid ConfigEventRequestDto dto){
return new CommonResponse<>(adminService.configLotsEvent(dto));
public CommonResponse<EventDto> configLotsEvent(
@RequestPart(value = "dto") @Valid ConfigEventRequestDto dto,
@RequestPart(value = "file", required = false) MultipartFile file
){
return new CommonResponse<>(adminService.configLotsEvent(dto, file));
}

@PutMapping("/arrival/rewardconfig")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,10 @@ public CommonResponse<?> eventNotFoundException(EventNotFoundException e, HttpSe
return new CommonResponse<>(ErrorCode.EVENT_NOT_FOUND_ERROR, e.getMessage());
}

@ExceptionHandler(S3RegisterFailureException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public CommonResponse<?> s3RegisterFailureException(S3RegisterFailureException e, HttpServletRequest request) {
log.warn("ADMIN-002> 요청 URI: " + request.getRequestURI() + ", 에러 메세지: " + e.getMessage());
return new CommonResponse<>(ErrorCode.S3_REGISTER_IMAGE_FAILURE_ERROR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.softeer.podo.admin.exception;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public class S3RegisterFailureException extends RuntimeException {
private String message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,4 @@ public class ConfigEventRequestDto {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime endAt;

private String tagImage;
}
69 changes: 57 additions & 12 deletions src/main/java/com/softeer/podo/admin/service/AdminService.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.softeer.podo.admin.service;


import com.amazonaws.services.s3.AmazonS3;
import com.softeer.podo.admin.exception.S3RegisterFailureException;
import com.softeer.podo.admin.exception.EventNotFoundException;
import com.softeer.podo.admin.model.dto.*;
import com.softeer.podo.admin.model.dto.request.ConfigEventRequestDto;
Expand All @@ -9,6 +11,8 @@
import com.softeer.podo.admin.model.dto.response.EventListResponseDto;
import com.softeer.podo.admin.model.mapper.EventMapper;
import com.softeer.podo.admin.model.mapper.UserMapper;
import com.softeer.podo.admin.model.dto.LotsUserListDto;
import com.softeer.podo.common.utils.S3Utils;
import com.softeer.podo.event.model.entity.ArrivalUser;
import com.softeer.podo.event.model.entity.Event;
import com.softeer.podo.event.model.entity.EventReward;
Expand All @@ -19,15 +23,18 @@
import com.softeer.podo.event.repository.LotsUserRepository;
import jakarta.validation.ValidationException;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.io.IOException;
import java.util.*;

@Service
Expand All @@ -38,30 +45,69 @@ public class AdminService {
private final ArrivalUserRepository arrivalUserRepository;
private final EventRewardRepository eventRewardRepository;

private final Long arrivalEventId = 1L;
private final Long lotsEventId = 2L;
private final Long ARRIVAL_EVENT_ID = 1L;
private final Long LOTS_EVENT_ID = 2L;
private final int PAGE_SIZE = 10;

private final AmazonS3 amazonS3;

@Value("${cloud.aws.s3.bucket}")
private String bucket;

@Transactional
public EventListResponseDto getEventList() {
return EventMapper.eventListToEventListResponseDto(eventRepository.findAll());
}

/**
* 이미지 파일을 받아서 S3에 업로드하고, 선착순 이벤트 정보를 수정한 후 정보를 반환해준다.
* @param dto 이벤트 업로드 정보
* @param image 이벤트에 업로드할 이미지 파일
* @return 업로드된 이벤트 정보
*/
@Transactional
public EventDto configArrivalEvent(ConfigEventRequestDto dto) {
Event arrivalEvent = updateEventByConfigDto(arrivalEventId, dto);
public EventDto configArrivalEvent(ConfigEventRequestDto dto, MultipartFile image) {
Event arrivalEvent = updateEventByConfigDto(ARRIVAL_EVENT_ID, dto);

String imageUri = null;
if(image!=null) { // 이미지가 null이 아닌 경우 s3 업로드
try {
imageUri = S3Utils.saveFile(amazonS3, bucket, image);
} catch (IOException e) {
throw new S3RegisterFailureException("리뷰 이미지 저장 중 오류가 발생했습니다.");
}
}
arrivalEvent.updateTagImageLink(imageUri);

return EventMapper.EventToEventDto(arrivalEvent);
}

/**
* 이미지 파일을 받아서 S3에 업로드하고, 랜덤 이벤트 정보를 수정한 후 정보를 반환해준다.
* @param dto 이벤트 업로드 정보
* @param image 이벤트에 업로드할 이미지 파일
* @return 업로드된 이벤트 정보
*/
@Transactional
public EventDto configLotsEvent(ConfigEventRequestDto dto) {
Event lotsEvent = updateEventByConfigDto(lotsEventId, dto);
public EventDto configLotsEvent(ConfigEventRequestDto dto, MultipartFile image) {
Event lotsEvent = updateEventByConfigDto(LOTS_EVENT_ID, dto);

String imageUri = null;
if(image!=null) { // 이미지가 null이 아닌 경우 s3 업로드
try {
imageUri = S3Utils.saveFile(amazonS3, bucket, image);
} catch (IOException e) {
throw new S3RegisterFailureException("리뷰 이미지 저장 중 오류가 발생했습니다.");
}
}
lotsEvent.updateTagImageLink(imageUri);

return EventMapper.EventToEventDto(lotsEvent);
}

@Transactional
public ConfigEventRewardResponseDto configArrivalEventReward(ConfigEventRewardRequestDto dto) {
Event arrivalEvent = eventRepository.findById(arrivalEventId).orElseThrow(EventNotFoundException::new);
Event arrivalEvent = eventRepository.findById(ARRIVAL_EVENT_ID).orElseThrow(EventNotFoundException::new);
List<EventReward> arrivalRewards = eventRewardRepository.findByEvent(arrivalEvent);
eventRewardRepository.deleteAllInBatch(arrivalRewards);

Expand All @@ -87,7 +133,7 @@ public ConfigEventRewardResponseDto configArrivalEventReward(ConfigEventRewardRe

@Transactional
public ConfigEventRewardResponseDto configLotsEventReward(ConfigEventRewardRequestDto dto) {
Event lotsEvent = eventRepository.findById(lotsEventId).orElseThrow(EventNotFoundException::new);
Event lotsEvent = eventRepository.findById(LOTS_EVENT_ID).orElseThrow(EventNotFoundException::new);
List<EventReward> lotsRewards = eventRewardRepository.findByEvent(lotsEvent);
eventRewardRepository.deleteAllInBatch(lotsRewards);

Expand Down Expand Up @@ -146,7 +192,7 @@ public ArrivalUserListDto getArrivalApplicationList(int pageNo, String name, Str

ArrivalUserListDto arrivalUserListDto = UserMapper.ArrivalUserPageToArrivalUserListDto(page);
//선착순 이벤트 id
Event arrivalEvent = eventRepository.findById(arrivalEventId).orElseThrow(EventNotFoundException::new);
Event arrivalEvent = eventRepository.findById(ARRIVAL_EVENT_ID).orElseThrow(EventNotFoundException::new);
List<EventReward> eventRewardList = arrivalEvent.getEventRewardList();
// 보상 순위 기준으로 정렬
eventRewardList.sort(Comparator.comparingInt(EventReward::getRewardRank));
Expand Down Expand Up @@ -196,7 +242,7 @@ public LotsUserListDto getLotsApplicationList(int pageNo, String name, String ph
@Transactional
public LotsUserListDto getLotsResult() {
//랜덤 추첨 이벤트
Event lotsEvent = eventRepository.findById(lotsEventId).orElseThrow(EventNotFoundException::new);
Event lotsEvent = eventRepository.findById(LOTS_EVENT_ID).orElseThrow(EventNotFoundException::new);
//보상 리스트
List<EventReward> eventRewardList = lotsEvent.getEventRewardList();
//응모 목록
Expand Down Expand Up @@ -259,8 +305,7 @@ private Event updateEventByConfigDto(Long eventId, ConfigEventRequestDto dto) {
dto.getRepeatDay(),
dto.getRepeatTime(),
dto.getStartAt(),
dto.getEndAt(),
dto.getTagImage()
dto.getEndAt()
);
return event;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public enum ErrorCode {
INTERNAL_SERVER_ERROR(false,HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부에서 문제가 발생했습니다."),
NOT_FOUND(false, HttpStatus.NOT_FOUND.value(), "해당 로그인 정보는 존재하지 않습니다."),
UNAUTHORIZED(false, HttpStatus.UNAUTHORIZED.value(), "권한이 없습니다."),
S3_REGISTER_IMAGE_FAILURE_ERROR(false, HttpStatus.BAD_REQUEST.value(), "s3 이미지 저장 중 문제가 발생했습니다."),
S3_REGISTER_IMAGE_FAILURE_ERROR(false, HttpStatus.INTERNAL_SERVER_ERROR.value(), "s3 이미지 저장 중 문제가 발생했습니다."),

//auth
EMAIL_EXISTS_ERROR(false, HttpStatus.BAD_REQUEST.value(), "이미 존재하는 이메일입니다."),
Expand Down
68 changes: 68 additions & 0 deletions src/main/java/com/softeer/podo/common/utils/RequestUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.softeer.podo.common.utils;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;

import java.io.IOException;
import java.util.*;

/**
* Request에서 다양한 정보들을 파싱하기 위한 유틸리티 클래스
*/
public class RequestUtils {

public static Map<String, String> headerMap(HttpServletRequest request) {
Map<String, String> headerMap = new HashMap<>();
Enumeration<String> headerNames = request.getHeaderNames();
while(headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
// 클라이언트 식별정보는 저장하지 않음
if (name.equals("user-agent")) {
continue;
}
String value = request.getHeader(name);
headerMap.put(name, value);
}
return headerMap;
}

public static String getRequestUri(HttpServletRequest request) {
return "[" + request.getMethod() + "] " + request.getRequestURI();
}

public static String getParts(HttpServletRequest request) throws ServletException, IOException {
// 멀티파트 데이터 처리
StringBuilder sb = new StringBuilder();
sb.append("{");
Collection<Part> parts = request.getParts();
for(Part part : parts) {
// 멀티파트 파일 처리 로직
String name = part.getName();
// String filename = part.get();
// 파일 처리 로직 또는 로깅
// sb.append("\"").append(name).append("\" : \"").append(filename).append("\",");
}
sb.append("}");
return sb.toString();
}

public static Map<String, String> parameterMap(HttpServletRequest request) {
Map<String, String> parameterMap = new HashMap<>();
Map<String, String[]> parameterNames = request.getParameterMap();
// 쿼리 파라미터의 이름(key)
for (String key: parameterNames.keySet()) {
// 쿼리 파라미터 값(value)
String[] values = parameterNames.get(key);
// ,로 구분하여 map에 저장
StringJoiner valueString = new StringJoiner(",");
if (values != null) {
for (String value: values) {
valueString.add(value);
}
}
parameterMap.put(key, valueString.toString());
}
return parameterMap;
}
}
26 changes: 26 additions & 0 deletions src/main/java/com/softeer/podo/common/utils/S3Utils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.softeer.podo.common.utils;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.UUID;

/**
* S3 버킷에 파일을 올리고, 올라간 S3의 uri path를 반환하는 메서드
*/
public class S3Utils {

public static String saveFile(AmazonS3 amazonS3, String bucket, MultipartFile multipartFile) throws IOException {
// originalFilename에 Random UUID 붙여서 같은 파일명 덮어쓰기 방지
String originalFilename = multipartFile.getOriginalFilename() + "_" + UUID.randomUUID();

ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(multipartFile.getSize());
metadata.setContentType(multipartFile.getContentType());

amazonS3.putObject(bucket, originalFilename, multipartFile.getInputStream(), metadata);
return amazonS3.getUrl(bucket, originalFilename).toString();
}
}
22 changes: 17 additions & 5 deletions src/main/java/com/softeer/podo/config/FilterConfig.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package com.softeer.podo.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.softeer.podo.log.filter.RequestCollectionFilter;
import com.softeer.podo.log.filter.RequestJsonBodyCollectionFilter;
import com.softeer.podo.log.filter.RequestMultipartBodyCollectionFilter;
import com.softeer.podo.security.jwt.ExceptionHandleFilter;
import com.softeer.podo.security.jwt.JwtAuthenticationFilter;
import com.softeer.podo.security.jwt.TokenProvider;
Expand Down Expand Up @@ -39,11 +40,22 @@ public FilterRegistrationBean<ExceptionHandleFilter> exceptionHandleFilter() {
}

@Bean
public FilterRegistrationBean<RequestCollectionFilter> requestCollectionFilter() {
FilterRegistrationBean<RequestCollectionFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new RequestCollectionFilter());
registrationBean.addUrlPatterns("/admin/*"); // 필터를 적용할 URL 패턴
public FilterRegistrationBean<RequestMultipartBodyCollectionFilter> requestJsonCollectionFilter() {
FilterRegistrationBean<RequestMultipartBodyCollectionFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new RequestMultipartBodyCollectionFilter());
registrationBean.addUrlPatterns("/admin/arrival/config"); // 필터를 적용할 URL 패턴
registrationBean.addUrlPatterns("/admin/lots/config"); // 필터를 적용할 URL 패턴
registrationBean.setOrder(3); // 필터의 순서 (숫자가 낮을수록 먼저 실행됨)
return registrationBean;
}

@Bean
public FilterRegistrationBean<RequestJsonBodyCollectionFilter> requestJsonBodyCollectionFilter() {
FilterRegistrationBean<RequestJsonBodyCollectionFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new RequestJsonBodyCollectionFilter());
registrationBean.addUrlPatterns("/admin/arrival/rewardconfig"); // 필터를 적용할 URL 패턴
registrationBean.addUrlPatterns("/admin/lots/rewardconfig"); // 필터를 적용할 URL 패턴
registrationBean.setOrder(4); // 필터의 순서 (숫자가 낮을수록 먼저 실행됨)
return registrationBean;
}
}
Loading
Loading