From 905afe65217e21e5dc190e388b7f2ea6fdaf5181 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 12 May 2025 21:32:41 +0900 Subject: [PATCH 1/8] feat: add review model --- src/main/java/kr/mayb/data/model/Review.java | 43 +++++++++++++++++++ .../java/kr/mayb/data/model/ReviewImage.java | 23 ++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/main/java/kr/mayb/data/model/Review.java create mode 100644 src/main/java/kr/mayb/data/model/ReviewImage.java diff --git a/src/main/java/kr/mayb/data/model/Review.java b/src/main/java/kr/mayb/data/model/Review.java new file mode 100644 index 0000000..19875ab --- /dev/null +++ b/src/main/java/kr/mayb/data/model/Review.java @@ -0,0 +1,43 @@ +package kr.mayb.data.model; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.BatchSize; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@Table(schema = "mayb") +@Entity +public class Review extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false) + private String productName; + + @Column(nullable = false) + private String content; + + @Min(1) + @Max(5) + @Column(nullable = false) + private int starRating; + + @Column(nullable = false) + private long memberId; + + @Column(nullable = false) + private long productId; + + @BatchSize(size = 10) + @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) + private List reviewImages = new ArrayList<>(); +} diff --git a/src/main/java/kr/mayb/data/model/ReviewImage.java b/src/main/java/kr/mayb/data/model/ReviewImage.java new file mode 100644 index 0000000..a89fb91 --- /dev/null +++ b/src/main/java/kr/mayb/data/model/ReviewImage.java @@ -0,0 +1,23 @@ +package kr.mayb.data.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Table(schema = "mayb") +@Entity +public class ReviewImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column + private String imageUrl; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "review_id", nullable = false) + private Review review; +} From ef2ca6669c3411598b3bf9ac33cfe07e7ed68544 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 12 May 2025 23:21:55 +0900 Subject: [PATCH 2/8] feat: add getReviews API --- .../kr/mayb/controller/OrderController.java | 2 +- .../kr/mayb/controller/ReviewController.java | 32 +++++++++++++ src/main/java/kr/mayb/data/model/Member.java | 12 +++++ src/main/java/kr/mayb/data/model/Review.java | 10 ++-- .../data/repository/ReviewRepository.java | 12 +++++ src/main/java/kr/mayb/dto/ReviewDto.java | 45 ++++++++++++++++++ src/main/java/kr/mayb/enums/ReviewSort.java | 32 +++++++++++++ .../java/kr/mayb/facade/ReviewFacade.java | 47 +++++++++++++++++++ .../java/kr/mayb/service/ReviewService.java | 27 +++++++++++ 9 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 src/main/java/kr/mayb/controller/ReviewController.java create mode 100644 src/main/java/kr/mayb/data/repository/ReviewRepository.java create mode 100644 src/main/java/kr/mayb/dto/ReviewDto.java create mode 100644 src/main/java/kr/mayb/enums/ReviewSort.java create mode 100644 src/main/java/kr/mayb/facade/ReviewFacade.java create mode 100644 src/main/java/kr/mayb/service/ReviewService.java diff --git a/src/main/java/kr/mayb/controller/OrderController.java b/src/main/java/kr/mayb/controller/OrderController.java index c8d3245..d595aff 100644 --- a/src/main/java/kr/mayb/controller/OrderController.java +++ b/src/main/java/kr/mayb/controller/OrderController.java @@ -63,7 +63,7 @@ public ResponseEntity>>> @PatchMapping("orders/{orderId}/members/{memberId}/payment-status") public ResponseEntity> updatePaymentStatus(@PathVariable long orderId, @PathVariable long memberId, - @RequestBody @Valid OrderController.PaymentStatusUpdateRequest request) { + @RequestBody @Valid PaymentStatusUpdateRequest request) { OrderInfo response = orderFacade.updatePaymentStatus(orderId, memberId, request.status()); return Responses.ok(response); diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java new file mode 100644 index 0000000..83d415a --- /dev/null +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -0,0 +1,32 @@ +package kr.mayb.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.mayb.dto.ReviewDto; +import kr.mayb.facade.ReviewFacade; +import kr.mayb.security.PermitAll; +import kr.mayb.util.request.PageRequest; +import kr.mayb.util.response.ApiResponse; +import kr.mayb.util.response.PageResponse; +import kr.mayb.util.response.Responses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Review", description = "리뷰 API") +@RestController +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewFacade reviewFacade; + + @Operation(summary = "상품 리뷰 조회") + @PermitAll + @GetMapping("/reviews") + public ResponseEntity>> getReviews(@RequestParam(value = "pid") long productId, PageRequest pageRequest) { + PageResponse response = reviewFacade.getReviews(productId, pageRequest); + return Responses.ok(response); + } +} diff --git a/src/main/java/kr/mayb/data/model/Member.java b/src/main/java/kr/mayb/data/model/Member.java index 50e3efc..f3b2234 100644 --- a/src/main/java/kr/mayb/data/model/Member.java +++ b/src/main/java/kr/mayb/data/model/Member.java @@ -6,6 +6,7 @@ import kr.mayb.enums.Gender; import lombok.Getter; import lombok.Setter; +import org.apache.commons.lang3.StringUtils; import org.hibernate.annotations.BatchSize; import java.time.LocalDate; @@ -65,4 +66,15 @@ public class Member extends BaseEntity{ @Column @Enumerated(EnumType.STRING) private AccountStatus status; + + public String getMaskedName() { + if (StringUtils.isBlank(this.name) || this.name.length() <= 1) { + return this.name; + } + + String firstChar = this.name.substring(0, 1); + String masked = this.name.substring(1).replaceAll("\\.", "*"); + + return firstChar + masked; + } } diff --git a/src/main/java/kr/mayb/data/model/Review.java b/src/main/java/kr/mayb/data/model/Review.java index 19875ab..5d5eb4a 100644 --- a/src/main/java/kr/mayb/data/model/Review.java +++ b/src/main/java/kr/mayb/data/model/Review.java @@ -31,11 +31,13 @@ public class Review extends BaseEntity { @Column(nullable = false) private int starRating; - @Column(nullable = false) - private long memberId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; - @Column(nullable = false) - private long productId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; @BatchSize(size = 10) @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) diff --git a/src/main/java/kr/mayb/data/repository/ReviewRepository.java b/src/main/java/kr/mayb/data/repository/ReviewRepository.java new file mode 100644 index 0000000..d7f6494 --- /dev/null +++ b/src/main/java/kr/mayb/data/repository/ReviewRepository.java @@ -0,0 +1,12 @@ +package kr.mayb.data.repository; + +import kr.mayb.data.model.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { + @EntityGraph(attributePaths = {"reviewImages", "member"}) + Page findAllByProductId(long productId, Pageable pageable); +} diff --git a/src/main/java/kr/mayb/dto/ReviewDto.java b/src/main/java/kr/mayb/dto/ReviewDto.java new file mode 100644 index 0000000..45c9f59 --- /dev/null +++ b/src/main/java/kr/mayb/dto/ReviewDto.java @@ -0,0 +1,45 @@ +package kr.mayb.dto; + +import kr.mayb.data.model.Member; +import kr.mayb.data.model.Review; + +import java.time.OffsetDateTime; + +public record ReviewDto( + long reviewId, + String productName, + String content, + String author, + int starRating, + long memberId, + OffsetDateTime createdAt, + boolean isMyReview +) { + private ReviewDto(Review review, Member author, boolean isMyReview) { + this( + review.getId(), + review.getProductName(), + review.getContent(), + author.getMaskedName(), + review.getStarRating(), + author.getId(), + review.getCreatedAt(), + isMyReview + ); + } + + public static ReviewDto of(Review review) { + Member author = review.getMember(); + return new ReviewDto(review, author, false); + } + + public static ReviewDto of(Review review, long currentMemberId) { + Member author = review.getMember(); + boolean isMyReview = isMyReview(author.getId(), currentMemberId); + return new ReviewDto(review, author, isMyReview); + } + + private static boolean isMyReview(long authorId, long currentMemberId) { + return authorId == currentMemberId; + } +} diff --git a/src/main/java/kr/mayb/enums/ReviewSort.java b/src/main/java/kr/mayb/enums/ReviewSort.java new file mode 100644 index 0000000..43a82f3 --- /dev/null +++ b/src/main/java/kr/mayb/enums/ReviewSort.java @@ -0,0 +1,32 @@ +package kr.mayb.enums; + +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Sort; + +import java.util.Arrays; +import java.util.Optional; + +@RequiredArgsConstructor +public enum ReviewSort { + NEWEST_FIRST("createdAt"), + RATING_DESC("starRating"), + RATING_ASC("starRating"), + ; + + private final String value; + + public static Optional find(String name) { + return Arrays.stream(ReviewSort.values()) + .filter(v -> StringUtils.equalsIgnoreCase(v.name(), name)) + .findFirst(); + } + + public Sort toSortOption() { + return switch (this) { + case NEWEST_FIRST -> Sort.by(Sort.Direction.DESC, NEWEST_FIRST.value); + case RATING_DESC -> Sort.by(Sort.Direction.DESC, RATING_DESC.value); + case RATING_ASC -> Sort.by(Sort.Direction.ASC, RATING_ASC.value); + }; + } +} diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java new file mode 100644 index 0000000..e3cb743 --- /dev/null +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -0,0 +1,47 @@ +package kr.mayb.facade; + +import kr.mayb.data.model.Review; +import kr.mayb.dto.MemberDto; +import kr.mayb.dto.ReviewDto; +import kr.mayb.service.ReviewService; +import kr.mayb.util.ContextUtils; +import kr.mayb.util.request.PageRequest; +import kr.mayb.util.response.PageResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Objects; + +@Component +@RequiredArgsConstructor +public class ReviewFacade { + + private final ReviewService reviewService; + + private static List convertToReviewDto(Page reviews, Long currentMemberId) { + return reviews.getContent() + .stream() + .filter(Objects::nonNull) + .map(review -> { + if (currentMemberId == null) { + return ReviewDto.of(review); + } + + // Check whether the review is written by the current signIn member + return ReviewDto.of(review, currentMemberId); + }) + .toList(); + } + + public PageResponse getReviews(long productId, PageRequest pageRequest) { + Long currentMemberId = ContextUtils.getCurrentMember() + .map(MemberDto::getMemberId) + .orElse(null); + + Page reviews = reviewService.findAllByProductId(productId, pageRequest); + return PageResponse.of(new PageImpl<>(convertToReviewDto(reviews, currentMemberId), reviews.getPageable(), reviews.getTotalElements())); + } +} diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java new file mode 100644 index 0000000..760f4b3 --- /dev/null +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -0,0 +1,27 @@ +package kr.mayb.service; + +import kr.mayb.data.model.Review; +import kr.mayb.data.repository.ReviewRepository; +import kr.mayb.enums.ReviewSort; +import kr.mayb.util.request.PageRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ReviewRepository reviewRepository; + + public Page findAllByProductId(long productId, PageRequest pageRequest) { + Sort sort = ReviewSort.find(pageRequest.getSort()) + .map(ReviewSort::toSortOption) + .orElse(ReviewSort.NEWEST_FIRST.toSortOption()); + Pageable pageable = pageRequest.toPageable(sort); + + return reviewRepository.findAllByProductId(productId, pageable); + } +} From 5805facb769fdb54cc4dd72887e3300edd9a3b91 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Wed, 14 May 2025 19:55:59 +0900 Subject: [PATCH 3/8] feat: add write Review API --- .../kr/mayb/controller/ReviewController.java | 26 ++++- src/main/java/kr/mayb/data/model/Review.java | 20 ++-- .../mayb/data/repository/OrderRepository.java | 2 + src/main/java/kr/mayb/dto/ReviewDto.java | 19 +++- src/main/java/kr/mayb/dto/ReviewRequest.java | 15 +++ .../java/kr/mayb/enums/GcsBucketPath.java | 2 + .../java/kr/mayb/facade/ReviewFacade.java | 97 ++++++++++++++++--- .../java/kr/mayb/service/OrderService.java | 14 +++ .../java/kr/mayb/service/ProductService.java | 2 +- .../java/kr/mayb/service/ReviewService.java | 43 ++++++++ 10 files changed, 212 insertions(+), 28 deletions(-) create mode 100644 src/main/java/kr/mayb/dto/ReviewRequest.java diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java index 83d415a..0e0ca99 100644 --- a/src/main/java/kr/mayb/controller/ReviewController.java +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -3,17 +3,21 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import kr.mayb.dto.ReviewDto; +import kr.mayb.dto.ReviewRequest; +import kr.mayb.error.BadRequestException; import kr.mayb.facade.ReviewFacade; import kr.mayb.security.PermitAll; +import kr.mayb.security.PermitAuthenticated; import kr.mayb.util.request.PageRequest; import kr.mayb.util.response.ApiResponse; import kr.mayb.util.response.PageResponse; import kr.mayb.util.response.Responses; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @Tag(name = "Review", description = "리뷰 API") @RestController @@ -22,10 +26,24 @@ public class ReviewController { private final ReviewFacade reviewFacade; + @Operation(summary = "상품 리뷰 작성") + @PermitAuthenticated + @PostMapping("/reviews") + public ResponseEntity> writeReview(@RequestPart("review") ReviewRequest request, + @RequestPart("images") List images) { + if (images.size() > 5) { + throw new BadRequestException("Review images must be less than 5"); + } + + ReviewDto response = reviewFacade.writeReview(request, images); + return Responses.ok(response); + } + @Operation(summary = "상품 리뷰 조회") @PermitAll @GetMapping("/reviews") - public ResponseEntity>> getReviews(@RequestParam(value = "pid") long productId, PageRequest pageRequest) { + public ResponseEntity>> getReviews(@RequestParam(value = "pid") long productId, + PageRequest pageRequest) { PageResponse response = reviewFacade.getReviews(productId, pageRequest); return Responses.ok(response); } diff --git a/src/main/java/kr/mayb/data/model/Review.java b/src/main/java/kr/mayb/data/model/Review.java index 5d5eb4a..0d363ae 100644 --- a/src/main/java/kr/mayb/data/model/Review.java +++ b/src/main/java/kr/mayb/data/model/Review.java @@ -7,6 +7,7 @@ import lombok.Setter; import org.hibernate.annotations.BatchSize; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -20,9 +21,6 @@ public class Review extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; - @Column(nullable = false) - private String productName; - @Column(nullable = false) private String content; @@ -31,14 +29,22 @@ public class Review extends BaseEntity { @Column(nullable = false) private int starRating; + @Column + private String gender; + + @Column + private LocalDateTime scheduledAt; + + @Column + private long productId; + + @Column + private long orderId; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "member_id", nullable = false) private Member member; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "product_id", nullable = false) - private Product product; - @BatchSize(size = 10) @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) private List reviewImages = new ArrayList<>(); diff --git a/src/main/java/kr/mayb/data/repository/OrderRepository.java b/src/main/java/kr/mayb/data/repository/OrderRepository.java index cacf79e..8683fa6 100644 --- a/src/main/java/kr/mayb/data/repository/OrderRepository.java +++ b/src/main/java/kr/mayb/data/repository/OrderRepository.java @@ -12,4 +12,6 @@ public interface OrderRepository extends JpaRepository, JpaSpecific Page findAllByMemberId(long memberId, Pageable pageable); Optional findByIdAndMemberId(long orderId, long memberId); + + Optional findByProductIdAndMemberId(long productId, long memberId); } diff --git a/src/main/java/kr/mayb/dto/ReviewDto.java b/src/main/java/kr/mayb/dto/ReviewDto.java index 45c9f59..e3faf96 100644 --- a/src/main/java/kr/mayb/dto/ReviewDto.java +++ b/src/main/java/kr/mayb/dto/ReviewDto.java @@ -2,27 +2,34 @@ import kr.mayb.data.model.Member; import kr.mayb.data.model.Review; +import kr.mayb.data.model.ReviewImage; +import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.util.List; public record ReviewDto( long reviewId, - String productName, String content, String author, int starRating, + String gender, + LocalDateTime scheduledAt, long memberId, + List imageUrls, OffsetDateTime createdAt, boolean isMyReview ) { - private ReviewDto(Review review, Member author, boolean isMyReview) { + private ReviewDto(Review review, List imageUrls, Member author, boolean isMyReview) { this( review.getId(), - review.getProductName(), review.getContent(), author.getMaskedName(), review.getStarRating(), + review.getGender(), + review.getScheduledAt(), author.getId(), + imageUrls, review.getCreatedAt(), isMyReview ); @@ -30,13 +37,15 @@ private ReviewDto(Review review, Member author, boolean isMyReview) { public static ReviewDto of(Review review) { Member author = review.getMember(); - return new ReviewDto(review, author, false); + List imageUrls = review.getReviewImages().stream().map(ReviewImage::getImageUrl).toList(); + return new ReviewDto(review, imageUrls, author, false); } public static ReviewDto of(Review review, long currentMemberId) { Member author = review.getMember(); + List imageUrls = review.getReviewImages().stream().map(ReviewImage::getImageUrl).toList(); boolean isMyReview = isMyReview(author.getId(), currentMemberId); - return new ReviewDto(review, author, isMyReview); + return new ReviewDto(review, imageUrls, author, isMyReview); } private static boolean isMyReview(long authorId, long currentMemberId) { diff --git a/src/main/java/kr/mayb/dto/ReviewRequest.java b/src/main/java/kr/mayb/dto/ReviewRequest.java new file mode 100644 index 0000000..2bf0bce --- /dev/null +++ b/src/main/java/kr/mayb/dto/ReviewRequest.java @@ -0,0 +1,15 @@ +package kr.mayb.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ReviewRequest( + long productId, + + @Size(min = 1, max = 1000) + @NotBlank + String content, + + int starRating +) { +} diff --git a/src/main/java/kr/mayb/enums/GcsBucketPath.java b/src/main/java/kr/mayb/enums/GcsBucketPath.java index 10993d9..a37c4a2 100644 --- a/src/main/java/kr/mayb/enums/GcsBucketPath.java +++ b/src/main/java/kr/mayb/enums/GcsBucketPath.java @@ -8,6 +8,7 @@ public enum GcsBucketPath { PROFILE("profile/"), PRODUCT_PROFILE("product_profile/"), PRODUCT_DETAIL("product_detail/"), + REVIEW("review/") ; private final String value; @@ -17,6 +18,7 @@ public static String getPath(GcsBucketPath pathType) { case PROFILE -> PROFILE.value; case PRODUCT_PROFILE -> PRODUCT_PROFILE.value; case PRODUCT_DETAIL -> PRODUCT_DETAIL.value; + case REVIEW -> REVIEW.value; default -> throw new BadRequestException("Invalid GcsPathType" + pathType); }; } diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java index e3cb743..ad25ffa 100644 --- a/src/main/java/kr/mayb/facade/ReviewFacade.java +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -1,27 +1,111 @@ package kr.mayb.facade; +import jakarta.transaction.Transactional; +import kr.mayb.data.model.Member; +import kr.mayb.data.model.Product; import kr.mayb.data.model.Review; import kr.mayb.dto.MemberDto; +import kr.mayb.dto.OrderedProductItem; import kr.mayb.dto.ReviewDto; -import kr.mayb.service.ReviewService; +import kr.mayb.dto.ReviewRequest; +import kr.mayb.enums.GcsBucketPath; +import kr.mayb.enums.PaymentStatus; +import kr.mayb.error.BadRequestException; +import kr.mayb.error.ExternalApiException; +import kr.mayb.service.*; import kr.mayb.util.ContextUtils; import kr.mayb.util.request.PageRequest; import kr.mayb.util.response.PageResponse; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.IntStream; @Component @RequiredArgsConstructor public class ReviewFacade { private final ReviewService reviewService; + private final ImageService imageService; + private final MemberService memberService; + private final ProductService productService; + private final OrderService orderService; - private static List convertToReviewDto(Page reviews, Long currentMemberId) { + private final ThreadPoolTaskExecutor executor; + + @Transactional + public ReviewDto writeReview(ReviewRequest request, List images) { + MemberDto member = ContextUtils.getCurrentMember() + .orElseThrow(() -> new BadRequestException("Only signed-in members can write reviews.")); + + Member author = memberService.getMember(member.getMemberId()); + Product product = productService.getProduct(request.productId()); + Pair orderItem = getParticipatedProduct(author, product); + + if (images.isEmpty()) { + Review saved = reviewService.save(request, product.getId(), orderItem, List.of(), author); + orderService.updateReviewStatus(orderItem.getLeft()); + return ReviewDto.of(saved, author.getId()); + } + + List imageUrls = uploadImages(images); + Review saved = reviewService.save(request, product.getId(), orderItem, imageUrls, author); + orderService.updateReviewStatus(orderItem.getLeft()); + return ReviewDto.of(saved, author.getId()); + } + + private Pair getParticipatedProduct(Member author, Product product) { + return orderService.findByProductIdAndMemberId(author.getId(), product.getId()) + .filter(orderOpt -> orderOpt.getPaymentStatus() == PaymentStatus.COMPLETED) + .map(orderOpt -> { + OrderedProductItem productItem = productService.findOrderedProductItem(product.getId(), orderOpt.getId(), orderOpt.getProductScheduleId()); + return Pair.of(orderOpt.getId(), productItem); + }) + .orElseThrow(() -> new BadRequestException("Only members who have purchased the product can write reviews.")); + } + + public PageResponse getReviews(long productId, PageRequest pageRequest) { + Long currentMemberId = ContextUtils.getCurrentMember() + .map(MemberDto::getMemberId) + .orElse(null); + + Page reviews = reviewService.findAllByProductId(productId, pageRequest); + return PageResponse.of(new PageImpl<>(convertToReviewDto(reviews, currentMemberId), reviews.getPageable(), reviews.getTotalElements())); + } + + private List uploadImages(List images) { + // Upload images asynchronously + Map> uploadImageAsyncMap = IntStream.range(0, images.size()) + .boxed() + .collect(Collectors.toMap( + index -> index, + index -> CompletableFuture.supplyAsync(() -> + imageService.upload(images.get(index), GcsBucketPath.REVIEW), executor) + .exceptionally(e -> { + throw new ExternalApiException("Failed to upload review Images" + e.getMessage()); + }) + )); + + // Wait for all asynchronous image uploads to complete, sort by original order, and return the list of URLs. + return uploadImageAsyncMap.entrySet() + .stream() + .sorted(Map.Entry.comparingByKey()) + .map(Map.Entry::getValue) + .map(CompletableFuture::join) + .toList(); + } + + private List convertToReviewDto(Page reviews, Long currentMemberId) { return reviews.getContent() .stream() .filter(Objects::nonNull) @@ -35,13 +119,4 @@ private static List convertToReviewDto(Page reviews, Long cur }) .toList(); } - - public PageResponse getReviews(long productId, PageRequest pageRequest) { - Long currentMemberId = ContextUtils.getCurrentMember() - .map(MemberDto::getMemberId) - .orElse(null); - - Page reviews = reviewService.findAllByProductId(productId, pageRequest); - return PageResponse.of(new PageImpl<>(convertToReviewDto(reviews, currentMemberId), reviews.getPageable(), reviews.getTotalElements())); - } } diff --git a/src/main/java/kr/mayb/service/OrderService.java b/src/main/java/kr/mayb/service/OrderService.java index 526f4a7..79d885f 100644 --- a/src/main/java/kr/mayb/service/OrderService.java +++ b/src/main/java/kr/mayb/service/OrderService.java @@ -16,6 +16,8 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; +import java.util.Optional; + @Service @RequiredArgsConstructor public class OrderService { @@ -60,4 +62,16 @@ public Order updatePaymentStatus(long orderId, long memberId, PaymentStatus paym return orderRepository.save(order); } + + @Transactional + public void updateReviewStatus(Long orderId) { + Order order = orderRepository.findById(orderId) + .orElseThrow(() -> new ResourceNotFoundException("There is no Order with orderId." + orderId)); + + order.setHasReviewed(true); + } + + public Optional findByProductIdAndMemberId(long productId, long memberId) { + return orderRepository.findByProductIdAndMemberId(productId, memberId); + } } diff --git a/src/main/java/kr/mayb/service/ProductService.java b/src/main/java/kr/mayb/service/ProductService.java index 816c26b..d93a1f2 100644 --- a/src/main/java/kr/mayb/service/ProductService.java +++ b/src/main/java/kr/mayb/service/ProductService.java @@ -205,7 +205,7 @@ private ProductGenderPrice getGenderPrice(long priceId, Product product) { .orElseThrow(() -> new ResourceNotFoundException("Price not found: " + priceId)); } - private Product getProduct(long productId) { + public Product getProduct(long productId) { return productRepository.findById(productId) .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + productId)); } diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java index 760f4b3..e52f6f8 100644 --- a/src/main/java/kr/mayb/service/ReviewService.java +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -1,21 +1,64 @@ package kr.mayb.service; +import jakarta.transaction.Transactional; +import kr.mayb.data.model.Member; import kr.mayb.data.model.Review; +import kr.mayb.data.model.ReviewImage; import kr.mayb.data.repository.ReviewRepository; +import kr.mayb.dto.OrderedProductItem; +import kr.mayb.dto.ReviewRequest; import kr.mayb.enums.ReviewSort; import kr.mayb.util.request.PageRequest; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; +import java.util.List; + @Service @RequiredArgsConstructor public class ReviewService { private final ReviewRepository reviewRepository; + @Transactional + public Review save(ReviewRequest request, long productId, Pair orderItem, List imageUrls, Member author) { + Long orderId = orderItem.getLeft(); + OrderedProductItem orderedProduct = orderItem.getRight(); + String gender = orderedProduct.genderPrice().getGender(); + LocalDateTime scheduledAt = orderedProduct.schedule().getTimeSlot(); + + Review review = new Review(); + review.setContent(request.content()); + review.setStarRating(request.starRating()); + review.setGender(gender); + review.setScheduledAt(scheduledAt); + review.setProductId(productId); + review.setOrderId(orderId); + review.setMember(author); + + saveImages(review, imageUrls); + + return reviewRepository.save(review); + } + + private void saveImages(Review review, List imageUrls) { + List reviewImages = imageUrls.stream() + .map(url -> { + ReviewImage reviewImage = new ReviewImage(); + reviewImage.setReview(review); + reviewImage.setImageUrl(url); + return reviewImage; + }) + .toList(); + + review.setReviewImages(reviewImages); + } + public Page findAllByProductId(long productId, PageRequest pageRequest) { Sort sort = ReviewSort.find(pageRequest.getSort()) .map(ReviewSort::toSortOption) From d8e1d7b41e2d7908910ba2e5b6175d9bbba01acf Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Fri, 16 May 2025 14:17:30 +0900 Subject: [PATCH 4/8] feat: add getReview detail API --- .../kr/mayb/controller/ReviewController.java | 8 +++++++ .../java/kr/mayb/facade/ReviewFacade.java | 24 ++++++++++++------- .../java/kr/mayb/service/ReviewService.java | 5 ++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java index 0e0ca99..557600b 100644 --- a/src/main/java/kr/mayb/controller/ReviewController.java +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -39,6 +39,14 @@ public ResponseEntity> writeReview(@RequestPart("review") return Responses.ok(response); } + @Operation(summary = "상품 리뷰 상세 조회") + @PermitAll + @GetMapping("/reviews/{reviewId}") + public ResponseEntity> getReview(@PathVariable long reviewId) { + ReviewDto response = reviewFacade.getReview(reviewId); + return Responses.ok(response); + } + @Operation(summary = "상품 리뷰 조회") @PermitAll @GetMapping("/reviews") diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java index ad25ffa..c287a31 100644 --- a/src/main/java/kr/mayb/facade/ReviewFacade.java +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -12,6 +12,7 @@ import kr.mayb.enums.PaymentStatus; import kr.mayb.error.BadRequestException; import kr.mayb.error.ExternalApiException; +import kr.mayb.error.ResourceNotFoundException; import kr.mayb.service.*; import kr.mayb.util.ContextUtils; import kr.mayb.util.request.PageRequest; @@ -64,14 +65,11 @@ public ReviewDto writeReview(ReviewRequest request, List images) return ReviewDto.of(saved, author.getId()); } - private Pair getParticipatedProduct(Member author, Product product) { - return orderService.findByProductIdAndMemberId(author.getId(), product.getId()) - .filter(orderOpt -> orderOpt.getPaymentStatus() == PaymentStatus.COMPLETED) - .map(orderOpt -> { - OrderedProductItem productItem = productService.findOrderedProductItem(product.getId(), orderOpt.getId(), orderOpt.getProductScheduleId()); - return Pair.of(orderOpt.getId(), productItem); - }) - .orElseThrow(() -> new BadRequestException("Only members who have purchased the product can write reviews.")); + public ReviewDto getReview(long reviewId) { + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + return ReviewDto.of(review); } public PageResponse getReviews(long productId, PageRequest pageRequest) { @@ -83,6 +81,16 @@ public PageResponse getReviews(long productId, PageRequest page return PageResponse.of(new PageImpl<>(convertToReviewDto(reviews, currentMemberId), reviews.getPageable(), reviews.getTotalElements())); } + private Pair getParticipatedProduct(Member author, Product product) { + return orderService.findByProductIdAndMemberId(author.getId(), product.getId()) + .filter(orderOpt -> orderOpt.getPaymentStatus() == PaymentStatus.COMPLETED) + .map(orderOpt -> { + OrderedProductItem productItem = productService.findOrderedProductItem(product.getId(), orderOpt.getId(), orderOpt.getProductScheduleId()); + return Pair.of(orderOpt.getId(), productItem); + }) + .orElseThrow(() -> new BadRequestException("Only members who have purchased the product can write reviews.")); + } + private List uploadImages(List images) { // Upload images asynchronously Map> uploadImageAsyncMap = IntStream.range(0, images.size()) diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java index e52f6f8..a04f163 100644 --- a/src/main/java/kr/mayb/service/ReviewService.java +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -18,6 +18,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -67,4 +68,8 @@ public Page findAllByProductId(long productId, PageRequest pageRequest) return reviewRepository.findAllByProductId(productId, pageable); } + + public Optional findById(long reviewId) { + return reviewRepository.findById(reviewId); + } } From 199d1a28aed82347c8a213640f5eb6a5f40b063c Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Fri, 16 May 2025 15:00:02 +0900 Subject: [PATCH 5/8] feat: add getReview detail API --- .../kr/mayb/controller/ReviewController.java | 19 +++++++++++++++++++ .../java/kr/mayb/facade/ReviewFacade.java | 8 ++++++++ .../java/kr/mayb/service/ReviewService.java | 17 +++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java index 557600b..0e1a1a6 100644 --- a/src/main/java/kr/mayb/controller/ReviewController.java +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -2,6 +2,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import kr.mayb.dto.ReviewDto; import kr.mayb.dto.ReviewRequest; import kr.mayb.error.BadRequestException; @@ -55,4 +58,20 @@ public ResponseEntity>> getReviews(@Re PageResponse response = reviewFacade.getReviews(productId, pageRequest); return Responses.ok(response); } + + @Operation(summary = "상품 리뷰 수정") + @PermitAuthenticated + @PutMapping("/reviews/{reviewId}") + public ResponseEntity> updateReview(@PathVariable long reviewId, @RequestBody @Valid ReviewUpdateRequest request) { + ReviewDto response = reviewFacade.updateReview(reviewId, request.content(), request.starRating()); + return Responses.ok(response); + } + + private record ReviewUpdateRequest( + @NotBlank + String content, + @NotNull + int starRating + ) { + } } diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java index c287a31..13267a9 100644 --- a/src/main/java/kr/mayb/facade/ReviewFacade.java +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -1,6 +1,7 @@ package kr.mayb.facade; import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotBlank; import kr.mayb.data.model.Member; import kr.mayb.data.model.Product; import kr.mayb.data.model.Review; @@ -127,4 +128,11 @@ private List convertToReviewDto(Page reviews, Long currentMem }) .toList(); } + + public ReviewDto updateReview(long reviewId, @NotBlank String content, int starRating) { + MemberDto member = ContextUtils.loadMember(); + + Review updated = reviewService.updateReview(reviewId, content, starRating, member.getMemberId()); + return ReviewDto.of(updated, member.getMemberId()); + } } diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java index a04f163..538596e 100644 --- a/src/main/java/kr/mayb/service/ReviewService.java +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -8,12 +8,14 @@ import kr.mayb.dto.OrderedProductItem; import kr.mayb.dto.ReviewRequest; import kr.mayb.enums.ReviewSort; +import kr.mayb.error.ResourceNotFoundException; import kr.mayb.util.request.PageRequest; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -69,6 +71,21 @@ public Page findAllByProductId(long productId, PageRequest pageRequest) return reviewRepository.findAllByProductId(productId, pageable); } + @Transactional + public Review updateReview(long reviewId, String content, int starRating, long memberId) { + Review review = findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + if (review.getMember().getId() != memberId) { + throw new AccessDeniedException("Only author can update review. : " + memberId); + } + + review.setContent(content); + review.setStarRating(starRating); + + return reviewRepository.save(review); + } + public Optional findById(long reviewId) { return reviewRepository.findById(reviewId); } From feac7c1b999f11380ae7051e6122069e5a50bbb7 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Fri, 16 May 2025 15:33:29 +0900 Subject: [PATCH 6/8] feat: add addReviewImage API --- .../kr/mayb/controller/ReviewController.java | 9 +++++ .../repository/ReviewImageRepository.java | 7 ++++ src/main/java/kr/mayb/dto/ImageDto.java | 12 +++++++ src/main/java/kr/mayb/dto/ReviewDto.java | 15 ++++---- .../java/kr/mayb/facade/ReviewFacade.java | 36 +++++++++++++------ .../java/kr/mayb/service/ReviewService.java | 10 ++++++ 6 files changed, 70 insertions(+), 19 deletions(-) create mode 100644 src/main/java/kr/mayb/data/repository/ReviewImageRepository.java create mode 100644 src/main/java/kr/mayb/dto/ImageDto.java diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java index 0e1a1a6..a6f46f6 100644 --- a/src/main/java/kr/mayb/controller/ReviewController.java +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -5,6 +5,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import kr.mayb.dto.ImageDto; import kr.mayb.dto.ReviewDto; import kr.mayb.dto.ReviewRequest; import kr.mayb.error.BadRequestException; @@ -67,6 +68,14 @@ public ResponseEntity> updateReview(@PathVariable long re return Responses.ok(response); } + @Operation(summary = "상품 리뷰 이미지 추가 (리뷰 수정 시)") + @PermitAuthenticated + @PostMapping("/reviews/{reviewId}/images") + public ResponseEntity> addReviewImage(@PathVariable long reviewId, @RequestParam("image") MultipartFile image) { + ImageDto response = reviewFacade.addReviewImage(reviewId, image); + return Responses.ok(response); + } + private record ReviewUpdateRequest( @NotBlank String content, diff --git a/src/main/java/kr/mayb/data/repository/ReviewImageRepository.java b/src/main/java/kr/mayb/data/repository/ReviewImageRepository.java new file mode 100644 index 0000000..c7eb8ca --- /dev/null +++ b/src/main/java/kr/mayb/data/repository/ReviewImageRepository.java @@ -0,0 +1,7 @@ +package kr.mayb.data.repository; + +import kr.mayb.data.model.ReviewImage; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewImageRepository extends JpaRepository { +} diff --git a/src/main/java/kr/mayb/dto/ImageDto.java b/src/main/java/kr/mayb/dto/ImageDto.java new file mode 100644 index 0000000..3cf8932 --- /dev/null +++ b/src/main/java/kr/mayb/dto/ImageDto.java @@ -0,0 +1,12 @@ +package kr.mayb.dto; + +import kr.mayb.data.model.ReviewImage; + +public record ImageDto( + long reviewImageId, + String imageUrl +) { + public static ImageDto of(ReviewImage reviewImage) { + return new ImageDto(reviewImage.getId(), reviewImage.getImageUrl()); + } +} diff --git a/src/main/java/kr/mayb/dto/ReviewDto.java b/src/main/java/kr/mayb/dto/ReviewDto.java index e3faf96..611235c 100644 --- a/src/main/java/kr/mayb/dto/ReviewDto.java +++ b/src/main/java/kr/mayb/dto/ReviewDto.java @@ -2,7 +2,6 @@ import kr.mayb.data.model.Member; import kr.mayb.data.model.Review; -import kr.mayb.data.model.ReviewImage; import java.time.LocalDateTime; import java.time.OffsetDateTime; @@ -16,11 +15,11 @@ public record ReviewDto( String gender, LocalDateTime scheduledAt, long memberId, - List imageUrls, + List images, OffsetDateTime createdAt, boolean isMyReview ) { - private ReviewDto(Review review, List imageUrls, Member author, boolean isMyReview) { + private ReviewDto(Review review, List images, Member author, boolean isMyReview) { this( review.getId(), review.getContent(), @@ -29,7 +28,7 @@ private ReviewDto(Review review, List imageUrls, Member author, boolean review.getGender(), review.getScheduledAt(), author.getId(), - imageUrls, + images, review.getCreatedAt(), isMyReview ); @@ -37,15 +36,15 @@ private ReviewDto(Review review, List imageUrls, Member author, boolean public static ReviewDto of(Review review) { Member author = review.getMember(); - List imageUrls = review.getReviewImages().stream().map(ReviewImage::getImageUrl).toList(); - return new ReviewDto(review, imageUrls, author, false); + List images = review.getReviewImages().stream().map(ImageDto::of).toList(); + return new ReviewDto(review, images, author, false); } public static ReviewDto of(Review review, long currentMemberId) { Member author = review.getMember(); - List imageUrls = review.getReviewImages().stream().map(ReviewImage::getImageUrl).toList(); + List images = review.getReviewImages().stream().map(ImageDto::of).toList(); boolean isMyReview = isMyReview(author.getId(), currentMemberId); - return new ReviewDto(review, imageUrls, author, isMyReview); + return new ReviewDto(review, images, author, isMyReview); } private static boolean isMyReview(long authorId, long currentMemberId) { diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java index 13267a9..a42af3c 100644 --- a/src/main/java/kr/mayb/facade/ReviewFacade.java +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -5,10 +5,8 @@ import kr.mayb.data.model.Member; import kr.mayb.data.model.Product; import kr.mayb.data.model.Review; -import kr.mayb.dto.MemberDto; -import kr.mayb.dto.OrderedProductItem; -import kr.mayb.dto.ReviewDto; -import kr.mayb.dto.ReviewRequest; +import kr.mayb.data.model.ReviewImage; +import kr.mayb.dto.*; import kr.mayb.enums.GcsBucketPath; import kr.mayb.enums.PaymentStatus; import kr.mayb.error.BadRequestException; @@ -23,6 +21,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; @@ -82,6 +81,28 @@ public PageResponse getReviews(long productId, PageRequest page return PageResponse.of(new PageImpl<>(convertToReviewDto(reviews, currentMemberId), reviews.getPageable(), reviews.getTotalElements())); } + public ReviewDto updateReview(long reviewId, @NotBlank String content, int starRating) { + MemberDto member = ContextUtils.loadMember(); + + Review updated = reviewService.updateReview(reviewId, content, starRating, member.getMemberId()); + return ReviewDto.of(updated, member.getMemberId()); + } + + public ImageDto addReviewImage(long reviewId, MultipartFile image) { + MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + if (review.getMember().getId() != member.getMemberId()) { + throw new AccessDeniedException("Only author can update review. : " + member.getMemberId()); + } + + String imageUrl = imageService.upload(image, GcsBucketPath.REVIEW); + ReviewImage saved = reviewService.addImage(review, imageUrl); + + return ImageDto.of(saved); + } + private Pair getParticipatedProduct(Member author, Product product) { return orderService.findByProductIdAndMemberId(author.getId(), product.getId()) .filter(orderOpt -> orderOpt.getPaymentStatus() == PaymentStatus.COMPLETED) @@ -128,11 +149,4 @@ private List convertToReviewDto(Page reviews, Long currentMem }) .toList(); } - - public ReviewDto updateReview(long reviewId, @NotBlank String content, int starRating) { - MemberDto member = ContextUtils.loadMember(); - - Review updated = reviewService.updateReview(reviewId, content, starRating, member.getMemberId()); - return ReviewDto.of(updated, member.getMemberId()); - } } diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java index 538596e..9fa5385 100644 --- a/src/main/java/kr/mayb/service/ReviewService.java +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -4,6 +4,7 @@ import kr.mayb.data.model.Member; import kr.mayb.data.model.Review; import kr.mayb.data.model.ReviewImage; +import kr.mayb.data.repository.ReviewImageRepository; import kr.mayb.data.repository.ReviewRepository; import kr.mayb.dto.OrderedProductItem; import kr.mayb.dto.ReviewRequest; @@ -27,6 +28,7 @@ public class ReviewService { private final ReviewRepository reviewRepository; + private final ReviewImageRepository reviewImageRepository; @Transactional public Review save(ReviewRequest request, long productId, Pair orderItem, List imageUrls, Member author) { @@ -89,4 +91,12 @@ public Review updateReview(long reviewId, String content, int starRating, long m public Optional findById(long reviewId) { return reviewRepository.findById(reviewId); } + + public ReviewImage addImage(Review review, String imageUrl) { + ReviewImage reviewImage = new ReviewImage(); + reviewImage.setReview(review); + reviewImage.setImageUrl(imageUrl); + + return reviewImageRepository.save(reviewImage); + } } From 7a97a0cb06a16b31790207e03e36881e5593dc59 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Fri, 16 May 2025 15:48:37 +0900 Subject: [PATCH 7/8] feat: add removeReviewImage API --- .../kr/mayb/controller/ReviewController.java | 8 ++++++ .../java/kr/mayb/facade/ReviewFacade.java | 25 ++++++++++++++++--- .../java/kr/mayb/service/ImageService.java | 4 +-- .../java/kr/mayb/service/ReviewService.java | 10 ++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java index a6f46f6..cc78c9d 100644 --- a/src/main/java/kr/mayb/controller/ReviewController.java +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -76,6 +76,14 @@ public ResponseEntity> addReviewImage(@PathVariable long r return Responses.ok(response); } + @Operation(summary = "상품 리뷰 이미지 삭제 (리뷰 수정 시)") + @PermitAuthenticated + @DeleteMapping("/reviews/{reviewId}/images/{imageId}") + public ResponseEntity removeReviewImage(@PathVariable long reviewId, @PathVariable long imageId) { + reviewFacade.removeReviewImage(reviewId, imageId); + return Responses.noContent(); + } + private record ReviewUpdateRequest( @NotBlank String content, diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java index a42af3c..c5f96d9 100644 --- a/src/main/java/kr/mayb/facade/ReviewFacade.java +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -93,9 +93,7 @@ public ImageDto addReviewImage(long reviewId, MultipartFile image) { Review review = reviewService.findById(reviewId) .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); - if (review.getMember().getId() != member.getMemberId()) { - throw new AccessDeniedException("Only author can update review. : " + member.getMemberId()); - } + checkAuthor(review.getMember().getId(), member.getMemberId()); String imageUrl = imageService.upload(image, GcsBucketPath.REVIEW); ReviewImage saved = reviewService.addImage(review, imageUrl); @@ -103,6 +101,27 @@ public ImageDto addReviewImage(long reviewId, MultipartFile image) { return ImageDto.of(saved); } + @Transactional + public void removeReviewImage(long reviewId, long imageId) { + MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + checkAuthor(review.getMember().getId(), member.getMemberId()); + + ReviewImage image = reviewService.findImageById(imageId) + .orElseThrow(() -> new ResourceNotFoundException("Review image not found. : " + imageId)); + + imageService.delete(image.getImageUrl(), GcsBucketPath.REVIEW); + reviewService.removeImage(imageId); + } + + private void checkAuthor(long authorId, long memberId) { + if (authorId != memberId) { + throw new AccessDeniedException("Only author can update review. : " + memberId); + } + } + private Pair getParticipatedProduct(Member author, Product product) { return orderService.findByProductIdAndMemberId(author.getId(), product.getId()) .filter(orderOpt -> orderOpt.getPaymentStatus() == PaymentStatus.COMPLETED) diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java index 5f6936b..0c90763 100644 --- a/src/main/java/kr/mayb/service/ImageService.java +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -44,8 +44,8 @@ private String generateUniqueFileName() { .toString(); } - public void delete(String profileUrl, GcsBucketPath pathType) { - String uuidName = profileUrl.substring(profileUrl.lastIndexOf("/") + 1); + public void delete(String imageUrl, GcsBucketPath pathType) { + String uuidName = imageUrl.substring(imageUrl.lastIndexOf("/") + 1); String fullBlobName = GcsBucketPath.getPath(pathType) + uuidName; gcsService.delete(fullBlobName); diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java index 9fa5385..0e5fc9d 100644 --- a/src/main/java/kr/mayb/service/ReviewService.java +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -92,6 +92,7 @@ public Optional findById(long reviewId) { return reviewRepository.findById(reviewId); } + @Transactional public ReviewImage addImage(Review review, String imageUrl) { ReviewImage reviewImage = new ReviewImage(); reviewImage.setReview(review); @@ -99,4 +100,13 @@ public ReviewImage addImage(Review review, String imageUrl) { return reviewImageRepository.save(reviewImage); } + + @Transactional + public void removeImage(long imageId) { + reviewImageRepository.deleteById(imageId); + } + + public Optional findImageById(long imageId) { + return reviewImageRepository.findById(imageId); + } } From 2a5bd1942af2a0660a65572ff106c19533163048 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Fri, 16 May 2025 16:52:30 +0900 Subject: [PATCH 8/8] feat: add removeReview API --- .../kr/mayb/controller/ReviewController.java | 10 +++++- .../mayb/data/repository/OrderRepository.java | 2 -- src/main/java/kr/mayb/dto/ReviewRequest.java | 2 ++ .../java/kr/mayb/facade/ReviewFacade.java | 31 +++++++++++++------ .../java/kr/mayb/service/OrderService.java | 6 ++-- .../java/kr/mayb/service/ReviewService.java | 19 +++++------- 6 files changed, 44 insertions(+), 26 deletions(-) diff --git a/src/main/java/kr/mayb/controller/ReviewController.java b/src/main/java/kr/mayb/controller/ReviewController.java index cc78c9d..f6d532e 100644 --- a/src/main/java/kr/mayb/controller/ReviewController.java +++ b/src/main/java/kr/mayb/controller/ReviewController.java @@ -34,7 +34,7 @@ public class ReviewController { @PermitAuthenticated @PostMapping("/reviews") public ResponseEntity> writeReview(@RequestPart("review") ReviewRequest request, - @RequestPart("images") List images) { + @RequestPart(value = "images", required = false) List images) { if (images.size() > 5) { throw new BadRequestException("Review images must be less than 5"); } @@ -84,6 +84,14 @@ public ResponseEntity removeReviewImage(@PathVariable long reviewId, @Path return Responses.noContent(); } + @Operation(summary = "상품 리뷰 삭제") + @PermitAuthenticated + @DeleteMapping("/reviews/{reviewId}") + public ResponseEntity removeReview(@PathVariable long reviewId) { + reviewFacade.removeReview(reviewId); + return Responses.noContent(); + } + private record ReviewUpdateRequest( @NotBlank String content, diff --git a/src/main/java/kr/mayb/data/repository/OrderRepository.java b/src/main/java/kr/mayb/data/repository/OrderRepository.java index 8683fa6..cacf79e 100644 --- a/src/main/java/kr/mayb/data/repository/OrderRepository.java +++ b/src/main/java/kr/mayb/data/repository/OrderRepository.java @@ -12,6 +12,4 @@ public interface OrderRepository extends JpaRepository, JpaSpecific Page findAllByMemberId(long memberId, Pageable pageable); Optional findByIdAndMemberId(long orderId, long memberId); - - Optional findByProductIdAndMemberId(long productId, long memberId); } diff --git a/src/main/java/kr/mayb/dto/ReviewRequest.java b/src/main/java/kr/mayb/dto/ReviewRequest.java index 2bf0bce..6dc6cc5 100644 --- a/src/main/java/kr/mayb/dto/ReviewRequest.java +++ b/src/main/java/kr/mayb/dto/ReviewRequest.java @@ -6,6 +6,8 @@ public record ReviewRequest( long productId, + long orderId, + @Size(min = 1, max = 1000) @NotBlank String content, diff --git a/src/main/java/kr/mayb/facade/ReviewFacade.java b/src/main/java/kr/mayb/facade/ReviewFacade.java index c5f96d9..4b906bd 100644 --- a/src/main/java/kr/mayb/facade/ReviewFacade.java +++ b/src/main/java/kr/mayb/facade/ReviewFacade.java @@ -3,7 +3,6 @@ import jakarta.transaction.Transactional; import jakarta.validation.constraints.NotBlank; import kr.mayb.data.model.Member; -import kr.mayb.data.model.Product; import kr.mayb.data.model.Review; import kr.mayb.data.model.ReviewImage; import kr.mayb.dto.*; @@ -50,17 +49,16 @@ public ReviewDto writeReview(ReviewRequest request, List images) .orElseThrow(() -> new BadRequestException("Only signed-in members can write reviews.")); Member author = memberService.getMember(member.getMemberId()); - Product product = productService.getProduct(request.productId()); - Pair orderItem = getParticipatedProduct(author, product); + Pair orderItem = getOrderedProduct(request.orderId(), request.productId(), author); if (images.isEmpty()) { - Review saved = reviewService.save(request, product.getId(), orderItem, List.of(), author); + Review saved = reviewService.save(request, orderItem, List.of(), author); orderService.updateReviewStatus(orderItem.getLeft()); return ReviewDto.of(saved, author.getId()); } List imageUrls = uploadImages(images); - Review saved = reviewService.save(request, product.getId(), orderItem, imageUrls, author); + Review saved = reviewService.save(request, orderItem, imageUrls, author); orderService.updateReviewStatus(orderItem.getLeft()); return ReviewDto.of(saved, author.getId()); } @@ -83,8 +81,12 @@ public PageResponse getReviews(long productId, PageRequest page public ReviewDto updateReview(long reviewId, @NotBlank String content, int starRating) { MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + checkAuthor(review.getMember().getId(), member.getMemberId()); - Review updated = reviewService.updateReview(reviewId, content, starRating, member.getMemberId()); + Review updated = reviewService.update(review, content, starRating); return ReviewDto.of(updated, member.getMemberId()); } @@ -116,17 +118,28 @@ public void removeReviewImage(long reviewId, long imageId) { reviewService.removeImage(imageId); } + public void removeReview(long reviewId) { + MemberDto member = ContextUtils.loadMember(); + Review review = reviewService.findById(reviewId) + .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); + + checkAuthor(review.getMember().getId(), member.getMemberId()); + + reviewService.remove(review.getId()); + } + private void checkAuthor(long authorId, long memberId) { if (authorId != memberId) { throw new AccessDeniedException("Only author can update review. : " + memberId); } } - private Pair getParticipatedProduct(Member author, Product product) { - return orderService.findByProductIdAndMemberId(author.getId(), product.getId()) + private Pair getOrderedProduct(long orderId, long productId, Member author) { + return orderService.find(orderId, author.getId()) + .filter(order -> order.getProductId() == productId) .filter(orderOpt -> orderOpt.getPaymentStatus() == PaymentStatus.COMPLETED) .map(orderOpt -> { - OrderedProductItem productItem = productService.findOrderedProductItem(product.getId(), orderOpt.getId(), orderOpt.getProductScheduleId()); + OrderedProductItem productItem = productService.findOrderedProductItem(productId, orderOpt.getId(), orderOpt.getProductScheduleId()); return Pair.of(orderOpt.getId(), productItem); }) .orElseThrow(() -> new BadRequestException("Only members who have purchased the product can write reviews.")); diff --git a/src/main/java/kr/mayb/service/OrderService.java b/src/main/java/kr/mayb/service/OrderService.java index 79d885f..3b2fd72 100644 --- a/src/main/java/kr/mayb/service/OrderService.java +++ b/src/main/java/kr/mayb/service/OrderService.java @@ -55,7 +55,7 @@ public Page getOrders(Long productId, PaymentStatus paymentStatus, PageRe @Transactional public Order updatePaymentStatus(long orderId, long memberId, PaymentStatus paymentStatus) { - Order order = orderRepository.findByIdAndMemberId(orderId, memberId) + Order order = find(orderId, memberId) .orElseThrow(() -> new ResourceNotFoundException("There is no Order with orderId and memberId." + orderId + ", " + memberId)); order.setPaymentStatus(paymentStatus); @@ -71,7 +71,7 @@ public void updateReviewStatus(Long orderId) { order.setHasReviewed(true); } - public Optional findByProductIdAndMemberId(long productId, long memberId) { - return orderRepository.findByProductIdAndMemberId(productId, memberId); + public Optional find(long orderId, long memberId) { + return orderRepository.findByIdAndMemberId(orderId, memberId); } } diff --git a/src/main/java/kr/mayb/service/ReviewService.java b/src/main/java/kr/mayb/service/ReviewService.java index 0e5fc9d..a4b0d40 100644 --- a/src/main/java/kr/mayb/service/ReviewService.java +++ b/src/main/java/kr/mayb/service/ReviewService.java @@ -9,14 +9,12 @@ import kr.mayb.dto.OrderedProductItem; import kr.mayb.dto.ReviewRequest; import kr.mayb.enums.ReviewSort; -import kr.mayb.error.ResourceNotFoundException; import kr.mayb.util.request.PageRequest; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.tuple.Pair; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; -import org.springframework.security.access.AccessDeniedException; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -31,9 +29,10 @@ public class ReviewService { private final ReviewImageRepository reviewImageRepository; @Transactional - public Review save(ReviewRequest request, long productId, Pair orderItem, List imageUrls, Member author) { + public Review save(ReviewRequest request, Pair orderItem, List imageUrls, Member author) { Long orderId = orderItem.getLeft(); OrderedProductItem orderedProduct = orderItem.getRight(); + long productId = orderedProduct.product().getId(); String gender = orderedProduct.genderPrice().getGender(); LocalDateTime scheduledAt = orderedProduct.schedule().getTimeSlot(); @@ -74,14 +73,7 @@ public Page findAllByProductId(long productId, PageRequest pageRequest) } @Transactional - public Review updateReview(long reviewId, String content, int starRating, long memberId) { - Review review = findById(reviewId) - .orElseThrow(() -> new ResourceNotFoundException("Review not found. : " + reviewId)); - - if (review.getMember().getId() != memberId) { - throw new AccessDeniedException("Only author can update review. : " + memberId); - } - + public Review update(Review review, String content, int starRating) { review.setContent(content); review.setStarRating(starRating); @@ -106,6 +98,11 @@ public void removeImage(long imageId) { reviewImageRepository.deleteById(imageId); } + @Transactional + public void remove(long reviewId) { + reviewRepository.deleteById(reviewId); + } + public Optional findImageById(long imageId) { return reviewImageRepository.findById(imageId); }