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

[HEENDY-47-review-image] 리뷰 작성 시, 다중 이미지 업로드(S3) 구현 #28

Merged
merged 1 commit into from
Mar 3, 2024
Merged
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
13 changes: 13 additions & 0 deletions src/main/java/com/hyundai/app/config/ServletContextConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import com.hyundai.app.security.methodparam.AuthParameterResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.multipart.commons.CommonsMultipartResolver;
import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
Expand Down Expand Up @@ -42,4 +44,15 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
argumentResolvers.add(authParameterResolver);
}

@Bean
public CommonsMultipartResolver multipartResolver() {
CommonsMultipartResolver resolver = new CommonsMultipartResolver();
resolver.setDefaultEncoding("UTF-8");
resolver.setMaxUploadSize(104857560); // 10MB
resolver.setMaxUploadSizePerFile(2097152); // 2MB
resolver.setMaxInMemorySize(10485756);
return resolver;
}

}
12 changes: 12 additions & 0 deletions src/main/java/com/hyundai/app/member/service/AwsS3Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,29 @@
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.AmazonS3ClientBuilder;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.hyundai.app.exception.AdventureOfHeendyException;
import com.hyundai.app.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

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

/**
* @author 엄상은
* @since 2024/02/26
* AWS S3 서비스
*/
@Log4j
@Component
@RequiredArgsConstructor
public class AwsS3Config {
@Value("${aws.s3.access-key}")
private String accessKey;
Expand All @@ -43,4 +54,5 @@ public String uploadPngFile(String fileName, File file) {
);
return s3Client.getUrl(bucket, fileName).toString();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import springfox.documentation.annotations.ApiIgnore;
import org.springframework.http.MediaType;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

/**
* @author 황수영
Expand Down Expand Up @@ -45,15 +49,15 @@ public ResponseEntity<StoreResDto> getStoreDetail(
* @since 2024/02/14
* 매장 리뷰 작성
*/
@PostMapping("/{storeId}/reviews")
@PostMapping(value = "/{storeId}/reviews", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
@ApiOperation("매장 리뷰 작성 API")
public ResponseEntity<Void> createReview(
@PathVariable int storeId,
@RequestBody ReviewReqDto reviewReqDto,
@RequestPart(value = "reviewReqDto") ReviewReqDto reviewReqDto,
@RequestPart(value = "imageList", required = false) List<MultipartFile> imageList,
@ApiIgnore @MemberId String memberId
) {
storeService.createReview(storeId, memberId, reviewReqDto);
storeService.createReview(storeId, memberId, reviewReqDto, imageList);
return new ResponseEntity<>(HttpStatus.ACCEPTED);
}

}
5 changes: 3 additions & 2 deletions src/main/java/com/hyundai/app/store/domain/Review.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review extends BaseEntity {

private int id;
private String id;
private String memberId;
private int storeId;
private int isDeleted;
private int score;
private String content;
private List<Integer> hashtags;

public static Review create(ReviewReqDto reviewReqDto, int storeId, String memberId) {
public static Review of(String id, ReviewReqDto reviewReqDto, int storeId, String memberId) {
return Review.builder()
.id(id)
.score(reviewReqDto.getScore())
.content(reviewReqDto.getContent())
.memberId(memberId)
Expand Down
1 change: 0 additions & 1 deletion src/main/java/com/hyundai/app/store/dto/ReviewReqDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,4 @@ public class ReviewReqDto {
private int score;
private String content;
private List<Integer> hashtagIds;
// 이미지 이후에 추가
}
2 changes: 1 addition & 1 deletion src/main/java/com/hyundai/app/store/dto/ReviewResDto.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@NoArgsConstructor
@AllArgsConstructor
public class ReviewResDto {
private int id;
private String id;
private int score;
private String content;
private List<String> hashtags;
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/hyundai/app/store/mapper/StoreMapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ public interface StoreMapper {
void updateReviewCount(@Param("storeId") int storeId);
List<Review> getReviews(@Param("storeId") int storeId);
List<Store> getStoresByHashtagId(@Param("hashtagId") int hashtagId);
void saveReviewImage(@Param("reviewId") String reviewId, @Param("memberId") String memberId
, @Param("storeId") int storeId, @Param("image") String image);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.hyundai.app.store.service;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.CannedAccessControlList;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.hyundai.app.exception.AdventureOfHeendyException;
import com.hyundai.app.exception.ErrorCode;
import com.hyundai.app.member.service.AwsS3Config;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

/**
* @author 황수영
* @since 2024/03/04
* 리뷰 작성시, S3 이미지 다중 업로드 기능 구현용
*/
@Log4j
@Component
@RequiredArgsConstructor
public class S3ImageUploadService {

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

private final AwsS3Config awsS3Config; // TODO: AmazonS3 싱글톤으로 정의하기

Comment on lines +35 to +37
Copy link
Member Author

@sooyoungh sooyoungh Mar 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sangeun99 AmazonS3 싱글톤으로 정의할까요?
+) S3 config에 정의해주고, 기능 메소드들은 서비스단으로 분리해도 좋을것 같습니다 ㅎㅎ

/**
* @author 황수영
* @since 2024/03/03
* 리뷰 작성 시 이미지 다중 업로드
*/
public List<String> uploadReviewImages(List<MultipartFile> multipartFilelist) {
List<String> urlList = new ArrayList<>();
for (MultipartFile multipartFile : multipartFilelist){
String url = uploadImage(multipartFile);
urlList.add(url);
}
return urlList;
}

/**
* @author 황수영
* @since 2024/03/03
* 파일 개별 업로드 후, url 반환
*/
public String uploadImage(MultipartFile file) {
try (InputStream inputStream = file.getInputStream()) {
String fileName = UUID.randomUUID().toString()
.concat(getFileExtension(Objects.requireNonNull(file.getOriginalFilename())));

ObjectMetadata objectMetadata = new ObjectMetadata();
objectMetadata.setContentLength(file.getSize());
objectMetadata.setContentType(file.getContentType());
uploadFile(inputStream, objectMetadata, fileName);
return getFileUrl(fileName);
} catch(IllegalAccessException | IOException e) {
log.error("파일 변환 중 에러가 발생하였습니다. => 파일명 : " + file.getOriginalFilename());
throw new AdventureOfHeendyException(ErrorCode.SERVER_UNAVAILABLE);
}
}

/**
* @author 황수영
* @since 2024/03/03
* 파일의 확장자명 가져오기
*/
private String getFileExtension(String fileName) throws IllegalAccessException {
try {
return fileName.substring(fileName.lastIndexOf("."));
} catch (StringIndexOutOfBoundsException e) {
log.error("파일 형식이 유효하지 않습니다. => 파일명 : " + fileName);
throw new AdventureOfHeendyException(ErrorCode.SERVER_UNAVAILABLE);
}
}

public void uploadFile(InputStream inputStream, ObjectMetadata objectMeTadata, String fileName){
awsS3Config.AmazonS3Client().putObject(new PutObjectRequest(bucket, fileName, inputStream, objectMeTadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
}

public String getFileUrl(String fileName){
return awsS3Config.AmazonS3Client().getUrl(bucket, fileName).toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.hyundai.app.store.dto.ReviewReqDto;
import com.hyundai.app.store.dto.StoreResDto;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

Expand All @@ -13,6 +14,6 @@
public interface StoreService {

StoreResDto getStoreDetail(int storeId);
void createReview(int storeId, String memberId, ReviewReqDto reviewReqDto);
void createReview(int storeId, String memberId, ReviewReqDto reviewReqDto, List<MultipartFile> imagelist);
void createStoreHashtag(int storeId, List<Integer> hashtagIds);
}
29 changes: 24 additions & 5 deletions src/main/java/com/hyundai/app/store/service/StoreServiceImpl.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.hyundai.app.store.service;

import com.hyundai.app.exception.AdventureOfHeendyException;
import com.hyundai.app.member.service.AwsS3Config;
import com.hyundai.app.store.domain.Hashtag;
import com.hyundai.app.store.domain.Review;
import com.hyundai.app.store.domain.Store;
Expand All @@ -12,9 +13,11 @@
import lombok.extern.log4j.Log4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

import java.text.DecimalFormat;
import java.io.File;
import java.util.List;
import java.util.UUID;

import static com.hyundai.app.exception.ErrorCode.*;

Expand All @@ -33,6 +36,7 @@ public class StoreServiceImpl implements StoreService {

private final StoreMapper storeMapper;
private final HashtagMapper hashtagMapper;
private final S3ImageUploadService s3ImageUploadService;

/**
* @author 황수영
Expand Down Expand Up @@ -65,19 +69,34 @@ public StoreResDto getStoreDetail(int storeId) {
* 리뷰 작성
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void createReview(int storeId, String memberId, ReviewReqDto reviewReqDto) {
@Transactional
public void createReview(int storeId, String memberId, ReviewReqDto reviewReqDto, List<MultipartFile> imagelist) {
validateReviewRequest(reviewReqDto);
Review review = Review.create(reviewReqDto, storeId, memberId);
String reviewId = UUID.randomUUID().toString();
Review review = Review.of(reviewId, reviewReqDto, storeId, memberId);
storeMapper.saveReview(review);
createStoreHashtag(storeId, reviewReqDto.getHashtagIds());
log.debug("리뷰 작성" + review);

// 이미지 추가
List<String> imageUrlList = s3ImageUploadService.uploadReviewImages(imagelist);
for (String imageUrl : imageUrlList) {
log.debug("이미지 추가 reviewId : " + reviewId + ", memberId : " + memberId
+ ", storeId : " + storeId + ", imageUrl : " + imageUrl);
storeMapper.saveReviewImage(reviewId, memberId, storeId, imageUrl);
}

// 해시 태그 추가
createStoreHashtag(storeId, reviewReqDto.getHashtagIds());
// TODO: 리뷰별 해시태그 추가

//평점 업데이트
double newAvgScore = calcAvgScore(storeId, reviewReqDto.getScore());
storeMapper.updateAvgScore(storeId, newAvgScore);
storeMapper.updateReviewCount(storeId);
}



/**
* @author 황수영
* @since 2024/02/14
Expand Down
19 changes: 18 additions & 1 deletion src/main/resources/mapper/StoreMapper.xml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
, content
)
VALUES (
review_id_seq.nextval
#{review.id}
, #{review.memberId}
, #{review.storeId}
, #{review.score}
Expand Down Expand Up @@ -92,4 +92,21 @@
ORDER BY store_hashtag.count DESC
FETCH FIRST 5 ROWS ONLY
</select>

<insert id="saveReviewImage" >
INSERT INTO image (
id
, img_url
, store_id
, member_id
, review_id
)
VALUES (
image_id_seq.nextval
, #{image}
, #{storeId}
, #{memberId}
, #{reviewId}
)
</insert>
</mapper>
Loading