Skip to content

Commit

Permalink
[BE] feat: 리뷰 삭제 기능 구현 (#735)
Browse files Browse the repository at this point in the history
* feat: 해당 review에 달린 tag를 삭제하는 기능 추가

* feat: 해당 review에 달린 favorite을 삭제하는 기능 추가

* feat: NotAuthorOfReviewException 추가

* feat: 리뷰 삭제 기능 구현

* feat: s3 이미지 삭제 기능 구현

* test: 리뷰 삭제 기능에 대한 인수테스트 작성

* refactor: 리뷰 반영

* refactor: deleteAllByIdInBatch적용

* test: 리뷰 삭제 실패 케이스 추가

* refactor: updateProductImage 메서드 중복 제거

* feat: s3 파일 경로 지정 로직 추가

* refactor: 리뷰에 이미지가 존재할 때에만 s3 delete 로직 실행하도록 수정

* refactor: 리뷰 삭제 성공시 상태코드 204 반환

* test: 리뷰 삭제 성공시 상태코드 204 반환하도록 인수테스트 수정

* feat: s3 이미지 삭제 로직 이벤트 처리

* refactor: 이미지 있을 때만 이벤트 발행하던 로직을 이미지 유무 상관없이 이벤트 발행하도록 수정 (이미지 유무 처리를 이벤트리스너에서 하도록)

* test: 리뷰 삭제 이벤트 관련 테스트 추가

* test: 리뷰 삭제 이벤트 관련 테스트 보완

* refactor: ReviewTagRepositoryTest의 deleteByReview 테스트 간소화

* feat: application.yml에 스레드 풀 설정 추가

* refactor: member를 equals로 비교하도록 수정

* chore: 컨벤션 적용

* refactor: 세션 이름 복구

* refactor: 리뷰 반영

* refactor: reviewId 대신 review로 delete하도록 수정

* refactor: s3 이미지 삭제 실패 로그 문구 수정

* refactor: 리뷰 삭제시 deleteById 대신 delete로 수정

* feat: 리뷰 삭제 api 수정 사항 적용

* style: EventTest 메소드 줄바꿈
  • Loading branch information
hanueleee authored Oct 16, 2023
1 parent 62124a6 commit 5d2e717
Show file tree
Hide file tree
Showing 25 changed files with 858 additions and 24 deletions.
3 changes: 2 additions & 1 deletion backend/src/main/java/com/funeat/FuneatApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
import com.funeat.common.repository.BaseRepositoryImpl;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@EnableAsync
@SpringBootApplication
@EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class)
public class FuneatApplication {

public static void main(String[] args) {
SpringApplication.run(FuneatApplication.class, args);
}

}
2 changes: 2 additions & 0 deletions backend/src/main/java/com/funeat/common/ImageUploader.java
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@
public interface ImageUploader {

String upload(final MultipartFile image);

void delete(final String fileName);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ public S3UploadFailException(final CommonErrorCode errorCode) {
super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage()));
}
}

public static class S3DeleteFailException extends CommonException {
public S3DeleteFailException(final CommonErrorCode errorCode) {
super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage()));
}
}
}
19 changes: 19 additions & 0 deletions backend/src/main/java/com/funeat/common/s3/S3Uploader.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@
import static com.funeat.exception.CommonErrorCode.IMAGE_EXTENSION_ERROR_CODE;
import static com.funeat.exception.CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE;

import com.amazonaws.AmazonServiceException;
import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import com.funeat.common.ImageUploader;
import com.funeat.common.exception.CommonException.NotAllowedFileExtensionException;
import com.funeat.common.exception.CommonException.S3DeleteFailException;
import com.funeat.common.exception.CommonException.S3UploadFailException;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
Expand All @@ -21,8 +25,11 @@
@Profile("!test")
public class S3Uploader implements ImageUploader {

private static final int BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH = 31;
private static final List<String> INCLUDE_EXTENSIONS = List.of("image/jpeg", "image/png", "image/webp");

private final Logger log = LoggerFactory.getLogger(this.getClass());

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

Expand Down Expand Up @@ -53,6 +60,18 @@ public String upload(final MultipartFile image) {
}
}

@Override
public void delete(final String image) {
final String imageName = image.substring(BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH);
try {
final String key = folder + imageName;
amazonS3.deleteObject(bucket, key);
} catch (final AmazonServiceException e) {
log.error("S3 이미지 삭제에 실패했습니다. 이미지 경로 : {}", image);
throw new S3DeleteFailException(UNKNOWN_SERVER_ERROR_CODE);
}
}

private void validateExtension(final MultipartFile image) {
final String contentType = image.getContentType();
if (!INCLUDE_EXTENSIONS.contains(contentType)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
import com.funeat.member.domain.Member;
import com.funeat.member.domain.favorite.ReviewFavorite;
import com.funeat.review.domain.Review;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ReviewFavoriteRepository extends JpaRepository<ReviewFavorite, Long> {

Optional<ReviewFavorite> findByMemberAndReview(final Member member, final Review review);

void deleteByReview(final Review review);

List<ReviewFavorite> findByReview(final Review review);
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
Expand Down Expand Up @@ -69,4 +71,13 @@ public ResponseEntity<MemberRecipesResponse> getMemberRecipe(@AuthenticationPrin

return ResponseEntity.ok().body(response);
}

@Logging
@DeleteMapping("/reviews/{reviewId}")
public ResponseEntity<Void> deleteReview(@PathVariable final Long reviewId,
@AuthenticationPrincipal final LoginInfo loginInfo) {
reviewService.deleteReview(reviewId, loginInfo.getId());

return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
Expand Down Expand Up @@ -55,4 +57,13 @@ ResponseEntity<MemberReviewsResponse> getMemberReview(@AuthenticationPrincipal f
@GetMapping
ResponseEntity<MemberRecipesResponse> getMemberRecipe(@AuthenticationPrincipal final LoginInfo loginInfo,
@PageableDefault final Pageable pageable);

@Operation(summary = "리뷰 삭제", description = "자신이 작성한 리뷰를 삭제한다.")
@ApiResponse(
responseCode = "204",
description = "리뷰 삭제 성공."
)
@DeleteMapping
ResponseEntity<Void> deleteReview(@PathVariable final Long reviewId,
@AuthenticationPrincipal final LoginInfo loginInfo);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.funeat.review.application;

public class ReviewDeleteEvent {

private final String image;

public ReviewDeleteEvent(final String image) {
this.image = image;
}

public String getImage() {
return image;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.funeat.review.application;

import com.funeat.common.ImageUploader;
import io.micrometer.core.instrument.util.StringUtils;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Component
public class ReviewDeleteEventListener {

private final ImageUploader imageUploader;

public ReviewDeleteEventListener(final ImageUploader imageUploader) {
this.imageUploader = imageUploader;
}

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void deleteReviewImageInS3(final ReviewDeleteEvent event) {
final String image = event.getImage();
if (StringUtils.isBlank(image)) {
imageUploader.delete(image);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import static com.funeat.member.exception.MemberErrorCode.MEMBER_DUPLICATE_FAVORITE;
import static com.funeat.member.exception.MemberErrorCode.MEMBER_NOT_FOUND;
import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND;
import static com.funeat.review.exception.ReviewErrorCode.NOT_AUTHOR_OF_REVIEW;
import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND;

import com.funeat.common.ImageUploader;
Expand All @@ -27,6 +28,7 @@
import com.funeat.review.dto.ReviewFavoriteRequest;
import com.funeat.review.dto.SortingReviewDto;
import com.funeat.review.dto.SortingReviewsResponse;
import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException;
import com.funeat.review.exception.ReviewException.ReviewNotFoundException;
import com.funeat.review.persistence.ReviewRepository;
import com.funeat.review.persistence.ReviewTagRepository;
Expand All @@ -35,6 +37,7 @@
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
Expand All @@ -58,18 +61,21 @@ public class ReviewService {
private final ProductRepository productRepository;
private final ReviewFavoriteRepository reviewFavoriteRepository;
private final ImageUploader imageUploader;
private final ApplicationEventPublisher eventPublisher;

public ReviewService(final ReviewRepository reviewRepository, final TagRepository tagRepository,
final ReviewTagRepository reviewTagRepository, final MemberRepository memberRepository,
final ProductRepository productRepository,
final ReviewFavoriteRepository reviewFavoriteRepository, final ImageUploader imageUploader) {
final ReviewFavoriteRepository reviewFavoriteRepository,
final ImageUploader imageUploader, final ApplicationEventPublisher eventPublisher) {
this.reviewRepository = reviewRepository;
this.tagRepository = tagRepository;
this.reviewTagRepository = reviewTagRepository;
this.memberRepository = memberRepository;
this.productRepository = productRepository;
this.reviewFavoriteRepository = reviewFavoriteRepository;
this.imageUploader = imageUploader;
this.eventPublisher = eventPublisher;
}

@Transactional
Expand Down Expand Up @@ -124,14 +130,11 @@ private ReviewFavorite saveReviewFavorite(final Member member, final Review revi
}

@Transactional
public void updateProductImage(final Long reviewId) {
final Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId));
public void updateProductImage(final Long productId) {
final Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId));

final Product product = review.getProduct();
final Long productId = product.getId();
final PageRequest pageRequest = PageRequest.of(TOP, ONE);

final List<Review> topFavoriteReview = reviewRepository.findPopularReviewWithImage(productId, pageRequest);
if (!topFavoriteReview.isEmpty()) {
final String topFavoriteReviewImage = topFavoriteReview.get(TOP).getImage();
Expand Down Expand Up @@ -180,6 +183,46 @@ public MemberReviewsResponse findReviewByMember(final Long memberId, final Pagea
return MemberReviewsResponse.toResponse(pageDto, dtos);
}

@Transactional
public void deleteReview(final Long reviewId, final Long memberId) {
final Member member = memberRepository.findById(memberId)
.orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId));
final Review review = reviewRepository.findById(reviewId)
.orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId));
final Product product = review.getProduct();
final String image = review.getImage();

if (review.checkAuthor(member)) {
eventPublisher.publishEvent(new ReviewDeleteEvent(image));
deleteThingsRelatedToReview(review);
updateProductImage(product.getId());
return;
}
throw new NotAuthorOfReviewException(NOT_AUTHOR_OF_REVIEW, memberId);
}

private void deleteThingsRelatedToReview(final Review review) {
deleteReviewTags(review);
deleteReviewFavorites(review);
reviewRepository.delete(review);
}

private void deleteReviewTags(final Review review) {
final List<ReviewTag> reviewTags = reviewTagRepository.findByReview(review);
final List<Long> ids = reviewTags.stream()
.map(ReviewTag::getId)
.collect(Collectors.toList());
reviewTagRepository.deleteAllByIdInBatch(ids);
}

private void deleteReviewFavorites(final Review review) {
final List<ReviewFavorite> reviewFavorites = reviewFavoriteRepository.findByReview(review);
final List<Long> ids = reviewFavorites.stream()
.map(ReviewFavorite::getId)
.collect(Collectors.toList());
reviewFavoriteRepository.deleteAllByIdInBatch(ids);
}

public Optional<MostFavoriteReviewResponse> getMostFavoriteReview(final Long productId) {
final Product findProduct = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId));
Expand Down
4 changes: 4 additions & 0 deletions backend/src/main/java/com/funeat/review/domain/Review.java
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ public void minusFavoriteCount() {
this.favoriteCount--;
}

public boolean checkAuthor(final Member member) {
return Objects.equals(this.member, member);
}

public Long getId() {
return id;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
public enum ReviewErrorCode {

REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리뷰입니다. 리뷰 id를 확인하세요.", "3001"),
NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3002")
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ public ReviewNotFoundException(final ReviewErrorCode errorCode, final Long revie
super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), reviewId));
}
}

public static class NotAuthorOfReviewException extends ReviewException {
public NotAuthorOfReviewException(final ReviewErrorCode errorCode, final Long memberId) {
super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), memberId));
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.funeat.review.persistence;

import com.funeat.review.domain.Review;
import com.funeat.review.domain.ReviewTag;
import com.funeat.tag.domain.Tag;
import java.util.List;
Expand All @@ -16,4 +17,8 @@ public interface ReviewTagRepository extends JpaRepository<ReviewTag, Long> {
+ "GROUP BY rt.tag "
+ "ORDER BY cnt DESC")
List<Tag> findTop3TagsByReviewIn(final Long productId, final Pageable pageable);

void deleteByReview(final Review review);

List<ReviewTag> findByReview(final Review review);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import org.springframework.data.web.PageableDefault;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand Down Expand Up @@ -49,11 +50,12 @@ public ResponseEntity<Void> writeReview(@PathVariable final Long productId,

@Logging
@PatchMapping("/api/products/{productId}/reviews/{reviewId}")
public ResponseEntity<Void> toggleLikeReview(@PathVariable final Long reviewId,
public ResponseEntity<Void> toggleLikeReview(@PathVariable final Long productId,
@PathVariable final Long reviewId,
@AuthenticationPrincipal final LoginInfo loginInfo,
@RequestBody @Valid final ReviewFavoriteRequest request) {
reviewService.likeReview(reviewId, loginInfo.getId(), request);
reviewService.updateProductImage(reviewId);
reviewService.updateProductImage(productId);

return ResponseEntity.noContent().build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
Expand Down Expand Up @@ -42,7 +43,8 @@ ResponseEntity<Void> writeReview(@PathVariable final Long productId,
description = "리뷰 좋아요(취소) 성공."
)
@PatchMapping
ResponseEntity<Void> toggleLikeReview(@PathVariable final Long reviewId,
ResponseEntity<Void> toggleLikeReview(@PathVariable final Long productId,
@PathVariable final Long reviewId,
@AuthenticationPrincipal final LoginInfo loginInfo,
@RequestBody final ReviewFavoriteRequest request);

Expand Down
Loading

0 comments on commit 5d2e717

Please sign in to comment.