Skip to content

Commit

Permalink
✨ [FEAT] 평가(리뷰) API 구현 (#26)
Browse files Browse the repository at this point in the history
✨ [FEAT] 평가(리뷰) API 구현
  • Loading branch information
Properks authored Aug 12, 2024
2 parents f47d5e0 + 6e06934 commit acef4a8
Show file tree
Hide file tree
Showing 18 changed files with 710 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package com.example.template.domain.review.controller;

import com.example.template.domain.member.entity.Member;
import com.example.template.domain.review.dto.request.ReviewRequestDTO;
import com.example.template.domain.review.dto.response.ReviewResponseDTO;
import com.example.template.domain.review.entity.Keyword;
import com.example.template.domain.review.entity.Review;
import com.example.template.domain.review.entity.ReviewImg;
import com.example.template.domain.review.service.KeywordQueryService;
import com.example.template.domain.review.service.ReviewCommandService;
import com.example.template.domain.review.service.ReviewQueryService;
import com.example.template.global.annotation.AuthenticatedMember;
import com.example.template.global.apiPayload.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/reviews")
public class ReviewController {

private final ReviewQueryService reviewQueryService;
private final ReviewCommandService reviewCommandService;
private final KeywordQueryService keywordQueryService;

@PostMapping
@Operation(summary = "평가 생성 API", description = "Request Body를 이용하여 새로운 평가를 생성합니다. keywords를 제외하고는 모두 필요합니다.")
public ApiResponse<ReviewResponseDTO.CreateReviewResponseDTO> createReview(@AuthenticatedMember Member member,
@Valid @RequestBody ReviewRequestDTO.CreateReviewRequestDTO request) {
Review review = reviewCommandService.createReview(member, request);
return ApiResponse.onSuccess(HttpStatus.CREATED, ReviewResponseDTO.CreateReviewResponseDTO.from(review));
}

@GetMapping("/stations/{stationId}")
@Operation(summary = "충전소 평가 가져오는 API", description = "특정 충전소의 모든 평가를 정렬하고 무한 스크롤 방식으로 페이지네이션하여 반환")
@Parameters({
@Parameter(name = "stationId", description = "평가를 가져올 충전소의 ID"),
@Parameter(name = "query", description = "SCORE: 별점순, RECENT: 최신순"),
@Parameter(name = "lastId", description = "마지막으로 받은 평가의 id, 처음 가져올 때 -> 0")
})
public ApiResponse<List<ReviewResponseDTO.ReviewPreviewDTO>> getReviewsOfStations(@AuthenticatedMember Member member,
@PathVariable Long stationId,
@RequestParam(defaultValue = "0") Long lastId,
@RequestParam(defaultValue = "SCORE") String query,
@RequestParam(defaultValue = "10") int offset) {
List<Review> reviewList = reviewQueryService.getReviewsOfStations(stationId, lastId, query, offset);
return ApiResponse.onSuccess(reviewList
.stream()
.map(review -> ReviewResponseDTO.ReviewPreviewDTO.of(review, reviewCommandService.isRecommended(review.getId(), member)))
.toList()
);
}

@GetMapping("/users")
@Operation(summary = "본인 평가 목록 가져오는 API", description = "로그인된 유저가 작성한 평가 목록 전체 조회")
public ApiResponse<List<ReviewResponseDTO.ReviewPreviewDTO>> getReviewsOfUsers(@AuthenticatedMember Member member) {
List<Review> reviewList = reviewQueryService.getReviewsOfUsers(member);
return ApiResponse.onSuccess(reviewList
.stream()
.map(review -> ReviewResponseDTO.ReviewPreviewDTO.of(review, reviewCommandService.isRecommended(review.getId(), member)))
.toList()
);
}

@GetMapping("/{reviewId}")
@Operation(summary = "평가 하나의 정보 가져오는 API", description = "특정 평가 하나 조회")
public ApiResponse<ReviewResponseDTO.ReviewPreviewDTO> getReview(@AuthenticatedMember Member member,
@PathVariable Long reviewId) {
Review review = reviewQueryService.getReview(reviewId);
return ApiResponse.onSuccess(ReviewResponseDTO.ReviewPreviewDTO.of(review, reviewCommandService.isRecommended(review.getId(), member)));
}

@GetMapping("/keywords")
@Operation(summary = "평가의 키워드 가져오는 API", description = "평가 키워드 전부 가져오기")
public ApiResponse<List<ReviewResponseDTO.KeywordDTO>> getKeywords() {
List<Keyword> keywords = keywordQueryService.getKeywords();
return ApiResponse.onSuccess(keywords.stream().map(ReviewResponseDTO.KeywordDTO::from).toList());
}

@PatchMapping("/{reviewId}")
@Operation(summary = "평가 수정 API", description = "평가 하나 수정하기")
public ApiResponse<ReviewResponseDTO.ReviewPreviewDTO> updateReview(@AuthenticatedMember Member member,
@PathVariable Long reviewId,
@RequestBody ReviewRequestDTO.UpdateReviewRequestDTO request) {
Review review = reviewCommandService.updateReview(reviewId, request);
return ApiResponse.onSuccess(
ReviewResponseDTO.ReviewPreviewDTO.of(review, reviewCommandService.isRecommended(review.getId(), member))
);
}

@DeleteMapping("/{reviewId}")
@Operation(summary = "평가 삭제 API", description = "평가 하나 삭제하기")
public ApiResponse<Long> deleteReview(@PathVariable Long reviewId) {
Long id = reviewCommandService.deleteReview(reviewId);
return ApiResponse.onSuccess(id);
}

@PostMapping("/{reviewId}")
@Operation(summary = "평가 추천 API", description = "평가 추천 및 추천 취소 API")
public ApiResponse<Boolean> recommendReview(@AuthenticatedMember Member member, @PathVariable Long reviewId) {
return ApiResponse.onSuccess(reviewCommandService.recommendReview(member, reviewId));
}

@Operation(summary = "이미지 업로드", description = "평가에 첨부할 이미지를 미리 업로드")
@PostMapping(value = "/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<ReviewResponseDTO.ReviewImgDTO> uploadImages(
@RequestPart("images") List<MultipartFile> images) {
List<ReviewImg> reviewImgList = reviewCommandService.uploadImg(images);
return ApiResponse.onSuccess(ReviewResponseDTO.ReviewImgDTO.builder().
images(reviewImgList.stream().map(ReviewImg::getImgUrl).toList())
.build());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.example.template.domain.review.dto.request;

import com.example.template.domain.member.entity.Member;
import com.example.template.domain.review.entity.Keyword;
import com.example.template.domain.review.entity.Review;
import com.example.template.domain.station.entity.Station;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;

import java.util.List;

public class ReviewRequestDTO {

@Getter
public static class CreateReviewRequestDTO {
@NotBlank(message = "내용이 비어있습니다.")
@Schema(name = "content", description = "내용", example = "충전소 이용이 편리해요")
private String content;
// TODO: null 인 경우 무시하고 0으로 처리, 추후에 validator 추가 필요
@Min(0)
@Max(5)
@NotNull
@Schema(name = "score", description = "별점", example = "4.5")
private double score;
private List<Keyword> keywords;
@Schema(name = "stationId", description = "리뷰를 달 충전소의 id", example = "1")
@NotNull
private Long stationId;
private List<String> imgUrls;

public Review toReview(Member member, Station station) {
return Review.builder()
.content(this.content)
.score(this.score)
.recommendationNum(0)
.member(member)
.station(station)
.build();
}
}

@Getter
public static class UpdateReviewRequestDTO {
@NotBlank(message = "내용이 비어있습니다.")
@Schema(name = "content", description = "수정할 내용", example = "충전 속도가 빨라요 (수정)")
private String content;
// TODO: null 인 경우 무시하고 0으로 처리, 추후에 validator 추가 필요
@Min(0)
@Max(5)
@NotNull
@Schema(name = "score", description = "수정할 점수", example = "4.7")
private double score;
private List<Keyword> keywords;
private List<String> imgUrls;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.example.template.domain.review.dto.response;

import com.example.template.domain.member.dto.ProfileResponseDTO;
import com.example.template.domain.review.entity.Keyword;
import com.example.template.domain.review.entity.Review;
import com.example.template.domain.review.entity.ReviewImg;
import com.example.template.domain.review.entity.ReviewKeyword;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;
import java.util.List;

public class ReviewResponseDTO {

@Getter
@Builder
public static class CreateReviewResponseDTO{
private Long id;
private LocalDateTime createdAt;

public static CreateReviewResponseDTO from(Review review) {
return CreateReviewResponseDTO.builder()
.id(review.getId())
.createdAt(review.getCreatedAt())
.build();
}
}

@Getter
@Builder
public static class ReviewPreviewDTO {
private Long id;
private String content;
private Integer recommendationNum;
private List<KeywordDTO> keywords;
private List<String> images;
private double score;
// TODO: 공용 유저 응답으로 변경 예정
private ProfileResponseDTO.ProfileDTO member;
private boolean isRecommended;
private String username;

public static ReviewPreviewDTO of(Review review, boolean isRecommended) {
return ReviewPreviewDTO.builder()
.id(review.getId())
.content(review.getContent())
.recommendationNum(review.getRecommendationNum())
.keywords(review.getKeywords().stream().map(ReviewKeyword::getKeyword).map(KeywordDTO::from).toList())
.images(review.getImgList().stream().map(ReviewImg::getImgUrl).toList())
.score(review.getScore())
.member(ProfileResponseDTO.from(review.getMember()))
.isRecommended(isRecommended)
.build();
}
}

@Getter
@Builder
public static class KeywordDTO {
private String name;
private String content;
public static KeywordDTO from(Keyword keyword) {
return KeywordDTO.builder()
.name(keyword.name())
.content(keyword.getDescription())
.build();
}
}

@Getter
@Builder
public static class ReviewImgDTO {
private List<String> images;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
import jakarta.persistence.*;
import lombok.*;

import java.util.ArrayList;
import java.util.List;

@Builder
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Table(name = "review")
@Entity
public class Review extends BaseEntity {

Expand All @@ -35,4 +39,15 @@ public class Review extends BaseEntity {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "station_id")
private Station station;

@OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ReviewImg> imgList = new ArrayList<>();

@OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ReviewKeyword> keywords = new ArrayList<>();

public void update(String content, double score) {
this.content = content;
this.score = score;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,8 @@ public class ReviewImg {
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "review_id")
private Review review;

public void setReview(Review review) {
this.review = review;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.example.template.domain.review.enums;

public enum SortType {
RECENT, SCORE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.template.domain.review.exception;

import com.example.template.global.apiPayload.ApiResponse;
import com.example.template.global.apiPayload.code.status.BaseErrorCode;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;

@Getter
@AllArgsConstructor
public enum ReviewErrorCode implements BaseErrorCode {

NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW404", "평가를 찾지 못했습니다."),
QUERY_BAD_REQUEST(HttpStatus.BAD_REQUEST, "REVIEW400", "평가의 쿼리가 잘못되었습니다."),
SCORE_RANGE_ERROR(HttpStatus.BAD_REQUEST, "REVIEW404", "평가 점수가 범위 밖입니다."),
INVALID_IMG_URL(HttpStatus.BAD_REQUEST, "REVIEW404", "잘못된 이미지 경로입니다."),
;

private final HttpStatus httpStatus;
private final String code;
private final String message;

@Override
public ApiResponse<Void> getErrorResponse() {
return ApiResponse.onFailure(code, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.example.template.domain.review.exception;

import com.example.template.global.apiPayload.exception.GeneralException;

public class ReviewException extends GeneralException {
public ReviewException(ReviewErrorCode code) {
super(code);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.template.domain.review.repository;

import com.example.template.domain.review.entity.Review;
import com.example.template.domain.review.entity.ReviewImg;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ReviewImgRepository extends JpaRepository<ReviewImg, Long> {
List<ReviewImg> findAllByReviewIs(Review review);
List<ReviewImg> findAllByImgUrlIn(List<String> imgUrls);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.template.domain.review.repository;

import com.example.template.domain.review.entity.Review;
import com.example.template.domain.review.entity.ReviewKeyword;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface ReviewKeywordRepository extends JpaRepository<ReviewKeyword, Long> {
List<ReviewKeyword> findAllByReviewIs(Review review);
void deleteAllByReviewIs(Review review);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.template.domain.review.repository;

import com.example.template.domain.member.entity.Member;
import com.example.template.domain.review.entity.Review;
import com.example.template.domain.review.entity.ReviewRecommend;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ReviewRecommendRepository extends JpaRepository<ReviewRecommend, Long> {
Optional<ReviewRecommend> findByMemberIsAndReviewIs(Member member, Review review);
boolean existsByReviewIsAndMemberIs(Review review, Member member);
void deleteAllByMemberIs(Member member);
void deleteAllByReviewIs(Review review);
}
Loading

0 comments on commit acef4a8

Please sign in to comment.