Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

유저 상호 평가 api 추가 #515

Merged
merged 41 commits into from
Oct 6, 2023
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
4191896
feat: 사용자 평가 엔티티 추가
kwonyj1022 Sep 30, 2023
54a163b
feat: 사용자 평가 레포지토리 추가
kwonyj1022 Sep 30, 2023
f6b0ffc
feat: 사용자 신뢰도 계산 기능 추가
kwonyj1022 Sep 30, 2023
bfaf87c
feat: 사용자 신뢰도 제출 및 조회 서비스 추가
kwonyj1022 Sep 30, 2023
fb7c229
feat: 사용자 신뢰도 제출 및 조회 컨트롤러 추가
kwonyj1022 Sep 30, 2023
2854e7f
refactor: 개행 및 메서드 분리를 통해 가독성 개선
kwonyj1022 Sep 30, 2023
e398232
feat: 사용자 평가 관련 flyway 스크립트 추가
kwonyj1022 Sep 30, 2023
1b63216
docs: 유저 상호 평가 api 문서화 추가
kwonyj1022 Sep 30, 2023
8382a09
feat: 평가 등록 시 자격이 있는지 검증 로직 추가
kwonyj1022 Sep 30, 2023
2b0196e
rename: request dto와 response dto 패키지 이동
kwonyj1022 Oct 2, 2023
7f46005
rename: 지정한 유저가 받은 모든 평가 목록을 가져오는 controller 메서드 이름 변경
kwonyj1022 Oct 2, 2023
9f4e982
feat: 지정한 작성자가 지정한 경매에 제출한 평가를 조회하는 레포지토리 추가
kwonyj1022 Oct 3, 2023
a0da88b
feat: 지정한 작성자가 지정한 경매에 제출한 평가를 조회하는 서비스 추가
kwonyj1022 Oct 3, 2023
024891a
feat: 지정한 작성자가 지정한 경매에 제출한 평가를 조회하는 컨트롤러 추가
kwonyj1022 Oct 3, 2023
5318223
docs: 사용자가 경매 거래 상대에게 작성한 평가 조회 부분 문서화
kwonyj1022 Oct 3, 2023
762a2ab
test: 픽스처 객체 생성을 `@beforeEach`에서 하도록 변경
kwonyj1022 Oct 3, 2023
c511d0d
test: 테스트에 Non-ASCII 경고 억제 어노테이션 추가
kwonyj1022 Oct 3, 2023
f614eb0
refactor: null 데이터를 가진 dto를 매번 생성하지 않고 static 하게 갖고 있도록 변경
kwonyj1022 Oct 3, 2023
99f067c
refactor: 빌더를 사용한 생성자의 접근지정자를 private으로 변경
kwonyj1022 Oct 3, 2023
e5cd696
style: 개행 수정
kwonyj1022 Oct 3, 2023
0a52c91
feat: db에 평가 점수 필드에 null 불가 조건 추가
kwonyj1022 Oct 3, 2023
80e033e
test: 서비스 테스트에 픽스처 추가
kwonyj1022 Oct 3, 2023
ae7fbe2
refactor: 사용하지 않는 `@EntityGraph` 제거
kwonyj1022 Oct 3, 2023
5c79872
feat: 지정한 평가 아이디로 평가를 조회할 수 있는 api 추가
kwonyj1022 Oct 4, 2023
0956831
docs: 지정한 평가 아이디로 평가를 조회할 수 있는 api 문서화 추가
kwonyj1022 Oct 4, 2023
a06f221
feat: 평가 점수를 나타내는 VO 추가
kwonyj1022 Oct 4, 2023
03ef2f3
test: 픽스처 누락된 부분 추가
kwonyj1022 Oct 4, 2023
cfb2e05
feat: 사용자가 경매 거래에 작성한 평가 조회 uri 변경
kwonyj1022 Oct 4, 2023
88e905e
docs: 사용자가 경매 거래에 작성한 평가 조회 uri 변경에 따른 문서화 수정
kwonyj1022 Oct 4, 2023
e05ba4c
ci: 브랜치 최신화
kwonyj1022 Oct 5, 2023
7cf0cad
feat: 사용자 엔티티에 신뢰도 값객체 적용
kwonyj1022 Oct 5, 2023
093ed4b
fix: 사용자 엔티티에 신뢰도 값객체 적용으로 인한 컴파일 에러 해결
kwonyj1022 Oct 5, 2023
b4bfd1f
refactor: 사용자 엔티티 생성자 필드의 `@NotNull`을 `@NonNull`로 변경
kwonyj1022 Oct 5, 2023
e01012a
test: 실패하는 테스트 해결
kwonyj1022 Oct 5, 2023
9029205
refactor: 초기 상태의 신뢰도를 나타내는 상수를 활용하도록 생성자 로직 수정
kwonyj1022 Oct 5, 2023
92ae2d3
style: 불필요한 개행 제거
kwonyj1022 Oct 5, 2023
71ce00c
ci: 브랜치 최신화
kwonyj1022 Oct 5, 2023
cc1a857
ci: 충돌 해결
kwonyj1022 Oct 5, 2023
425fed3
ci: 브랜치 최신화
kwonyj1022 Oct 6, 2023
93a5468
ci: 충돌 해결
kwonyj1022 Oct 6, 2023
e6a5a8e
ci: 브랜치 최신화
kwonyj1022 Oct 6, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions backend/ddang/src/docs/asciidoc/docs.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -404,3 +404,29 @@ include::{snippets}/device-token-controller-test/디바이스_토큰을_저장_
==== 응답

include::{snippets}/device-token-controller-test/디바이스_토큰을_저장_또는_갱신한다/http-response.adoc[]

== 유저 상호 평가 API

=== 사용자 평가 등록

==== 요청

include::{snippets}/review-controller-test/평가를_등록한다/http-request.adoc[]
include::{snippets}/review-controller-test/평가를_등록한다/request-headers.adoc[]
include::{snippets}/review-controller-test/평가를_등록한다/request-fields.adoc[]

==== 응답

include::{snippets}/review-controller-test/평가를_등록한다/http-response.adoc[]

=== 지정한 사용자가 받은 평가 목록 조회

==== 요청

include::{snippets}/review-controller-test/주어진_사용자가_받은_평가_목록을_최신순으로_조회한다/http-request.adoc[]
include::{snippets}/review-controller-test/주어진_사용자가_받은_평가_목록을_최신순으로_조회한다/query-parameters.adoc[]

==== 응답

include::{snippets}/review-controller-test/주어진_사용자가_받은_평가_목록을_최신순으로_조회한다/http-response.adoc[]
include::{snippets}/review-controller-test/주어진_사용자가_받은_평가_목록을_최신순으로_조회한다/response-fields.adoc[]
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import com.ddang.ddang.report.application.exception.InvalidChatRoomReportException;
import com.ddang.ddang.report.application.exception.InvalidReportAuctionException;
import com.ddang.ddang.report.application.exception.InvalidReporterToAuctionException;
import com.ddang.ddang.review.application.exception.AlreadyReviewException;
import com.ddang.ddang.user.application.exception.UserNotFoundException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
Expand Down Expand Up @@ -325,6 +326,16 @@ public ResponseEntity<ExceptionResponse> handleDeviceTokenNotFoundException(fina
.body(new ExceptionResponse(ex.getMessage()));
}

@ExceptionHandler(AlreadyReviewException.class)
public ResponseEntity<ExceptionResponse> handleAlreadyReviewException(
final AlreadyReviewException ex
) {
logger.warn(String.format(EXCEPTION_FORMAT, AlreadyReviewException.class), ex);

return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ExceptionResponse(ex.getMessage()));
}

@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
final MethodArgumentNotValidException ex,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.ddang.ddang.review.application;

import com.ddang.ddang.auction.application.exception.AuctionNotFoundException;
import com.ddang.ddang.auction.domain.Auction;
import com.ddang.ddang.auction.infrastructure.persistence.JpaAuctionRepository;
import com.ddang.ddang.review.application.dto.CreateReviewDto;
import com.ddang.ddang.review.application.dto.ReadReviewDetailDto;
import com.ddang.ddang.review.application.dto.ReadReviewDto;
import com.ddang.ddang.review.application.exception.AlreadyReviewException;
import com.ddang.ddang.review.application.exception.InvalidUserToReview;
import com.ddang.ddang.review.domain.Review;
import com.ddang.ddang.review.infrastructure.persistence.JpaReviewRepository;
import com.ddang.ddang.user.application.exception.UserNotFoundException;
import com.ddang.ddang.user.domain.User;
import com.ddang.ddang.user.infrastructure.persistence.JpaUserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ReviewService {

private final JpaReviewRepository reviewRepository;
private final JpaAuctionRepository auctionRepository;
private final JpaUserRepository userRepository;

@Transactional
public Long create(final CreateReviewDto reviewDto) {
final Auction findAuction = auctionRepository.findById(reviewDto.auctionId())
.orElseThrow(() ->
new AuctionNotFoundException("해당 경매를 찾을 수 없습니다.")
);
final User writer = userRepository.findById(reviewDto.writerId())
.orElseThrow(() -> new UserNotFoundException("작성자 정보를 찾을 수 없습니다."));
final User target = userRepository.findById(reviewDto.targetId())
.orElseThrow(() -> new UserNotFoundException("평가 상대의 정보를 찾을 수 없습니다."));

validateWriterCanReview(findAuction, writer);

final Review review = reviewDto.toEntity(findAuction, writer, target);
final Review persistReview = saveReviewAndUpdateReliability(review, target);

return persistReview.getId();
}

private void validateWriterCanReview(final Auction auction, final User writer) {
if (!auction.isSellerOrWinner(writer, LocalDateTime.now())) {
throw new InvalidUserToReview("경매의 판매자 또는 최종 낙찰자만 평가가 가능합니다.");
}

validateAlreadyReviewed(auction, writer);
}

private void validateAlreadyReviewed(final Auction auction, final User writer) {
if (reviewRepository.existsByAuctionIdAndWriterId(auction.getId(), writer.getId())) {
throw new AlreadyReviewException("이미 평가하였습니다.");
}
}

private Review saveReviewAndUpdateReliability(final Review review, final User target) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

칭찬

오!! 이 부분 선택사항으로 메서드 분리를 요청하려고 했었는데 이미 해주셨네요
역시 엔초

final Review persistReview = reviewRepository.save(review);

final List<Review> targetReviews = reviewRepository.findAllByTargetId(target.getId());
target.updateReliability(targetReviews);
return persistReview;
Copy link
Member

Choose a reason for hiding this comment

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

필수

return을 윗 행과 개행 부탁드립니다!

}

public List<ReadReviewDto> readAllByTargetId(final Long targetId) {
final List<Review> targetReviews = reviewRepository.findAllByTargetId(targetId);

return targetReviews.stream()
.map(ReadReviewDto::from)
.toList();
}

public ReadReviewDetailDto read(final Long writerId, final Long auctionId) {
return reviewRepository.findByAuctionIdAndWriterId(auctionId, writerId)
.map(ReadReviewDetailDto::from)
.orElse(ReadReviewDetailDto.empty());
Copy link
Collaborator

Choose a reason for hiding this comment

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

칭찬

칭찬해요!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!@#!@#!@#!@#2

orElse()에서 빈 값을 반환하도록 해서 깔끔하게 처리해주셨네요

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.ddang.ddang.review.application.dto;

import com.ddang.ddang.auction.domain.Auction;
import com.ddang.ddang.review.domain.Review;
import com.ddang.ddang.review.presentation.dto.request.CreateReviewRequest;
import com.ddang.ddang.user.domain.User;

public record CreateReviewDto(Long auctionId, Long writerId, Long targetId, String content, Double score) {

public static CreateReviewDto of(final Long writerId, final CreateReviewRequest createReviewRequest) {
return new CreateReviewDto(
createReviewRequest.auctionId(),
writerId,
createReviewRequest.targetId(),
createReviewRequest.content(),
createReviewRequest.score()
);
}

public Review toEntity(final Auction auction, final User writer, final User target) {
return Review.builder()
.auction(auction)
.writer(writer)
.target(target)
.content(content)
.score(score)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.ddang.ddang.review.application.dto;

import com.ddang.ddang.review.domain.Review;

import javax.annotation.Nullable;

public record ReadReviewDetailDto(@Nullable Double score, @Nullable String content) {

private static final Double EMPTY_SCORE = null;
private static final String EMPTY_CONTENT = null;
Comment on lines +9 to +10
Copy link
Collaborator

Choose a reason for hiding this comment

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

칭찬

칭찬해요!!!!!!!!!!!!!!!!!!!!!!!!!!!!!@#!@!@!@#!@

동일한 null인데 무슨 의미인지 써주셔서 명확해진 것 같습니다


public static ReadReviewDetailDto empty() {
return new ReadReviewDetailDto(EMPTY_SCORE, EMPTY_CONTENT);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

선택

Suggested change
public static ReadReviewDetailDto empty() {
return new ReadReviewDetailDto(EMPTY_SCORE, EMPTY_CONTENT);
}
public static ReadReviewDetailDto EMPTY = new ReadReviewDetailDto(EMPTY_SCORE, EMPTY_CONTENT);

empty()의 결과는 두 값 모두 null로 변할 것 같지 않은데, 지금은 매번 새로운 인스턴스를 생성해서 반환하는 것으로 보입니다
static 필드로 빼서 한 번만 생성되게 하면 조금 더 깔끔해질 것 같습니다

public static ReadReviewDetailDto from(final Review review) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

선택

개행..?

return new ReadReviewDetailDto(review.getScore(), review.getContent());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.ddang.ddang.review.application.dto;

import com.ddang.ddang.review.domain.Review;

import java.time.LocalDateTime;

public record ReadReviewDto(
Long id,
ReadUserInReviewDto writer,
String content,
Double score,
LocalDateTime createdTime
) {

public static ReadReviewDto from(final Review review) {
return new ReadReviewDto(
review.getId(),
ReadUserInReviewDto.from(review.getWriter()),
review.getContent(),
review.getScore(),
review.getCreatedTime());
Copy link
Member

Choose a reason for hiding this comment

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

필수

간단한 컨벤션이지만, );을 개행해주시면 감사하겠습니다!

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.ddang.ddang.review.application.dto;

import com.ddang.ddang.user.domain.User;

public record ReadUserInReviewDto(Long id, String name, Long profileImageId, double reliability, String oauthId) {

public static ReadUserInReviewDto from(final User user) {
return new ReadUserInReviewDto(
user.getId(),
user.getName(),
user.getProfileImage().getId(),
user.getReliability(),
user.getOauthId()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ddang.ddang.review.application.exception;

public class AlreadyReviewException extends IllegalArgumentException {

public AlreadyReviewException(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ddang.ddang.review.application.exception;

public class InvalidUserToReview extends IllegalArgumentException {

public InvalidUserToReview(final String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.ddang.ddang.review.domain;

import com.ddang.ddang.auction.domain.Auction;
import com.ddang.ddang.common.entity.BaseCreateTimeEntity;
import com.ddang.ddang.user.domain.User;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import lombok.Builder;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@NoArgsConstructor
@Getter
@EqualsAndHashCode(of = "id", callSuper = false)
Copy link
Collaborator

Choose a reason for hiding this comment

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

질문

callSuper default가 false이던데 명시해주신 이유가 있으신가요?
다른 엔티티 보니까 명시되어 있는 엔티티도 있고 그렇지 않은 엔티티도 있어서 여쭤봅니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

이 부분은 명시를 하는게 좋을 것 같습니다 자꾸 빌드할때마다 gradle이 경고 띄워서 살짝 느려지더라고요..

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

네 저번에 붙이기로 했어서 붙였습니다. 아마 이전 엔티티들은 따로 리팩토링을 진행하지 않아서 명시가 안되어있는 것 같아요.

@ToString(of = {"id", "content", "score"})
public class Review extends BaseCreateTimeEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "auction_id", nullable = false, foreignKey = @ForeignKey(name = "fk_review_auction"))
private Auction auction;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "writer_id", nullable = false, foreignKey = @ForeignKey(name = "fk_review_writer"))
private User writer;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "target_id", nullable = false, foreignKey = @ForeignKey(name = "fk_review_target"))
private User target;

private String content;

private Double score;
Copy link
Collaborator

Choose a reason for hiding this comment

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

질문

Bid의 Price처럼 VO를 생성하지 않아도 괜찮을까요?
VO에 대해 어떤 기준을 정했던 것 같은데 오래되어 기억이 잘 안 나긴하네요..

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

음 score도 따지고 보면 0.5 단위라는 정책이 존재하니까 VO를 만들면 좋겠네요. 그런데 이게 부동소수점이라 정확히 비교할 수 있을지 몰겠어요.


@Builder
public Review(
Copy link
Collaborator

@apptie apptie Oct 3, 2023

Choose a reason for hiding this comment

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

질문 & 선택

@Builder를 통해 별도로 빌더를 제공해주는데도 생성자를 public으로 하신 이유가 있으실까요?

IDE에서 확인해보니 외부에서 해당 생성자를 사용하는 부분이 없던데 특별한 이유가 없으시다면 private으로 바꾸면 될 것 같습니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

앗 수정하겠습니다!

final Auction auction,
final User writer,
final User target,
final String content,
final Double score
) {
this.auction = auction;
this.writer = writer;
this.target = target;
this.content = content;
this.score = score;
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.ddang.ddang.review.infrastructure.persistence;

import com.ddang.ddang.review.domain.Review;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;
import java.util.Optional;

public interface JpaReviewRepository extends JpaRepository<Review, Long> {

boolean existsByAuctionIdAndWriterId(final Long auctionId, final Long writerId);

@EntityGraph(attributePaths = {"writer", "target"})
@Query("""
SELECT r FROM Review r JOIN FETCH r.writer w JOIN FETCH r.target t
WHERE t.id = :targetId
ORDER BY r.id DESC
""")
Copy link
Collaborator

@apptie apptie Oct 3, 2023

Choose a reason for hiding this comment

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

질문 & 필수

@Query의 내부가 결국에는 JPQL이기도 하고, @Query 내부에서 이미 JOIN FETCH를 쓰고 있어서 @EntityGraph를 생략해도 될 것처럼 보입니다

일단 @EntityGraph를 주석처리하고 테스트를 실행시켜보니 테스트가 모두 통과하기는 했는데, 특별한 이유가 있을 수 있으니 이 부분 확인해주시면 감사하겠습니다

질문에 대한 답변이 필수다? 암튼 그런 느낌입니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

n+1 제거할 때 둘 다 실험해보고 싶어서 써놨다가 entity graph 부분을 안지웠네요.. 리뷰 감사합니다!

List<Review> findAllByTargetId(final Long targetId);

Optional<Review> findByAuctionIdAndWriterId(final Long auctionId, final Long writerId);
}
Loading