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

Q&A 기능 추가 #526

Merged
merged 64 commits into from
Oct 10, 2023
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
dc709f1
feat: 질문과 답변에 대한 엔티티 추가
JJ503 Oct 3, 2023
df75dd7
feat: 질문 레포지토리 추가
JJ503 Oct 3, 2023
73a4b19
feat: 질문 등록 서비스 추가
JJ503 Oct 3, 2023
26078dd
feat: 질문 등록 api 추가
JJ503 Oct 3, 2023
4b7fb84
test: QuestionService 테스트 추가
JJ503 Oct 3, 2023
e14af04
feat: 답변 레포지토리 추가
JJ503 Oct 3, 2023
d81ff04
feat: 답변 등록 서비스 추가
JJ503 Oct 3, 2023
e0e6e8e
refactor: 컨트롤러 클래스 명 수정
JJ503 Oct 3, 2023
0d5f85f
feat: 답변 등록 api 추가
JJ503 Oct 3, 2023
ab415a0
feat: 질문 및 답변 조회 레파지토리 추가
JJ503 Oct 3, 2023
f54d0a9
feat: 질문 및 답변 조회 서비스 추가
JJ503 Oct 3, 2023
59fb738
rename: 하위 패키지 생성
JJ503 Oct 3, 2023
4224840
feat: 질문과 답변 목록 전체 조회 api 추가
JJ503 Oct 4, 2023
29076c3
test: 테스트 픽스처 접근 제어자 설정
JJ503 Oct 4, 2023
97aa6ea
docs: 고민 todo 추가
JJ503 Oct 4, 2023
12c6e3d
feat: 질문과 답변관련 flyway 스크립트 추가
JJ503 Oct 4, 2023
7cfcd3a
docs: api 문서 최신화
JJ503 Oct 4, 2023
0146a4e
refactor: 불필요한 개행 제거
JJ503 Oct 4, 2023
68c9497
feat: 경매 조회시 삭제된 경매를 제외해주는 메서드 추가
JJ503 Oct 4, 2023
3559bcd
refactor: 경매 조회시 삭제된 경매는 존재하지 않는 경매로 처리되도록 수정
JJ503 Oct 4, 2023
66b18d6
refactor: 개행 추가
JJ503 Oct 5, 2023
e35adcb
test: 불필요한 코드 제거
JJ503 Oct 5, 2023
636d934
test: 컨벤션에 맞춰 개행
JJ503 Oct 5, 2023
1d7e896
style: todo 제거
JJ503 Oct 5, 2023
1611cff
refactor: 예외처리를 적절한 커스텀 예외로 변경
JJ503 Oct 5, 2023
98ac03b
rename: QuestionAndAnswer를 Qna로 축약해 사용
JJ503 Oct 5, 2023
544ad4c
ci: 브랜치 최신화
JJ503 Oct 5, 2023
493260b
ci: 브랜치 최신화 충돌 문제 해결
JJ503 Oct 5, 2023
ea5df21
feat: 질문 삭제 기능 추가
JJ503 Oct 5, 2023
410426b
feat: 질문 삭제 기능 서비스 추가
JJ503 Oct 5, 2023
082d7d5
feat: 질문 삭제 기능 api 추가
JJ503 Oct 5, 2023
e33718c
refactor: 삭제된 질문은 조회되지 않도록 수정
JJ503 Oct 5, 2023
ce53640
test: 메서드 네이밍 수정
JJ503 Oct 5, 2023
5866b56
feat: 답변 삭제 기능 서비스 추가
JJ503 Oct 5, 2023
0dba066
feat: 답변 삭제 api 추가
JJ503 Oct 5, 2023
a1b178a
feat: 질문과 답변에 삭제 여부 필드 추가
JJ503 Oct 6, 2023
4da8433
feat: 질문 신고 엔티티, 레파지토리 추가
JJ503 Oct 6, 2023
16e2029
feat: 트랜잭션 어노테이션 추가
JJ503 Oct 6, 2023
7c50d90
feat: 질문 신고 등록 서비스 추가
JJ503 Oct 6, 2023
ae7751b
feat: 질문 신고 등록 api 추가
JJ503 Oct 6, 2023
4912784
feat: 질문 신고 조회 레포지토리 추가
JJ503 Oct 6, 2023
5a5896f
feat: 질문 신고 조회 서비스 추가
JJ503 Oct 6, 2023
ec7fdca
feat: 질문 신고 조회 api 추가
JJ503 Oct 6, 2023
0f0d3e9
feat: 답변 신고 엔티티, 레포지토리 추가
JJ503 Oct 6, 2023
1771054
feat: 답변 신고 등록 서비스 추가
JJ503 Oct 6, 2023
026d62d
feat: 답변 신고 등록 api 추가
JJ503 Oct 6, 2023
c06b726
feat: 답변 신고 조회 레포지토리 추가
JJ503 Oct 6, 2023
7d27cca
feat: 답변 신고 조회 서비스 추가 및 테스트 수정
JJ503 Oct 6, 2023
ef8a11d
feat: 답변 신고 조회 api 추가
JJ503 Oct 6, 2023
a929963
feat: flyway 스크립트 report 테이블 생성 쿼리 추가
JJ503 Oct 6, 2023
091bb9a
ci: 브랜치 최신화
JJ503 Oct 7, 2023
8d7c84a
ci: 충돌문제 해결
JJ503 Oct 7, 2023
3a20334
fix: 답변의 작성자 조회 시 질문된 경매의 판매자를 전달하도록 수정
JJ503 Oct 9, 2023
14f1477
test: 누락된 테스트 추가
JJ503 Oct 9, 2023
2370238
test: 누락된 테스트 추가
JJ503 Oct 9, 2023
c49fac1
refactor: import 와일드카드 제거
JJ503 Oct 9, 2023
e726017
refactor: 누락된 final 추가
JJ503 Oct 9, 2023
f35b69f
refactor: 컨벤션에 따른 개행 추가
JJ503 Oct 9, 2023
7ad8437
docs: 누락된 api 문서화 추가
JJ503 Oct 9, 2023
5285c0c
test: 누락된 테스트 추가
JJ503 Oct 9, 2023
e820761
fix: 신고 존재여부 조회 시 의도와 다른 로직 문제 해결
JJ503 Oct 9, 2023
b99d36c
test: 테스트 픽스처 수정
JJ503 Oct 9, 2023
624edc3
ci: 브랜치 최신화
JJ503 Oct 9, 2023
e79559b
test: 메서드 명 수정
JJ503 Oct 9, 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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
import com.ddang.ddang.image.infrastructure.local.exception.StoreImageFailureException;
import com.ddang.ddang.image.infrastructure.local.exception.UnsupportedImageFileExtensionException;
import com.ddang.ddang.notification.application.exception.NotificationFailedException;
import com.ddang.ddang.questionandanswer.application.exception.AlreadyAnsweredException;
import com.ddang.ddang.questionandanswer.application.exception.InvalidAnswererException;
import com.ddang.ddang.questionandanswer.application.exception.InvalidAuctionToAskQuestionException;
import com.ddang.ddang.questionandanswer.application.exception.InvalidQuestionerException;
import com.ddang.ddang.region.application.exception.RegionNotFoundException;
import com.ddang.ddang.report.application.exception.AlreadyReportAuctionException;
import com.ddang.ddang.report.application.exception.AlreadyReportChatRoomException;
Expand Down Expand Up @@ -325,6 +329,38 @@ public ResponseEntity<ExceptionResponse> handleDeviceTokenNotFoundException(fina
.body(new ExceptionResponse(ex.getMessage()));
}

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

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

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

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

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

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

@ExceptionHandler(AlreadyAnsweredException.class)
public ResponseEntity<ExceptionResponse> handleAlreadyAnsweredException(final AlreadyAnsweredException ex) {
logger.warn(String.format(EXCEPTION_FORMAT, AlreadyAnsweredException.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,58 @@
package com.ddang.ddang.questionandanswer.application;

import com.ddang.ddang.auction.application.exception.AuctionNotFoundException;
import com.ddang.ddang.questionandanswer.application.dto.CreateAnswerDto;
import com.ddang.ddang.questionandanswer.application.exception.AlreadyAnsweredException;
import com.ddang.ddang.questionandanswer.application.exception.InvalidAnswererException;
import com.ddang.ddang.questionandanswer.domain.Answer;
import com.ddang.ddang.questionandanswer.domain.Question;
import com.ddang.ddang.questionandanswer.infrastructure.JpaAnswerRepository;
import com.ddang.ddang.questionandanswer.infrastructure.JpaQuestionRepository;
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;

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

private final JpaUserRepository userRepository;
private final JpaQuestionRepository questionRepository;
private final JpaAnswerRepository answerRepository;


Copy link
Collaborator

Choose a reason for hiding this comment

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

선택

개행 2줄..?

Copy link
Member Author

Choose a reason for hiding this comment

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

이... 이게 무슨일이야..!
감사합니다 🙇‍♀️

@Transactional
public Long create(final CreateAnswerDto answerDto) {
final User writer = userRepository.findById(answerDto.userId())
.orElseThrow(() -> new UserNotFoundException("해당 사용자를 찾을 수 없습니다."));
final Question question = questionRepository.findById(answerDto.questionId())
.orElseThrow(() ->
new AuctionNotFoundException("해당 질문을 찾을 수 없습니다.")
);
Copy link
Collaborator

Choose a reason for hiding this comment

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

필수

질문을 찾을 수 없는 것인데 AuctionNotFoundException이 터지네요

Copy link
Member Author

Choose a reason for hiding this comment

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

아니 무슨일이죠...
감사합니다 엔초!


checkInvalidAnswerer(question, writer);
checkAlreadyAnswered(question);

final Answer answer = answerDto.toEntity();
question.addAnswer(answer);

return answerRepository.save(answer)
.getId();
}

private void checkInvalidAnswerer(final Question question, final User writer) {
if (!question.isAnsweringAllowed(writer)) {
throw new InvalidAnswererException("판매자만 답변할 수 있습니다.");
}
}

private void checkAlreadyAnswered(final Question question) {
if (answerRepository.existsByQuestionId(question.getId())) {
throw new AlreadyAnsweredException("이미 답변한 질문입니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.ddang.ddang.questionandanswer.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.questionandanswer.application.dto.CreateQuestionDto;
import com.ddang.ddang.questionandanswer.application.exception.InvalidAuctionToAskQuestionException;
import com.ddang.ddang.questionandanswer.application.exception.InvalidQuestionerException;
import com.ddang.ddang.questionandanswer.domain.Question;
import com.ddang.ddang.questionandanswer.infrastructure.JpaQuestionRepository;
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;

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

private final JpaAuctionRepository auctionRepository;
private final JpaUserRepository userRepository;
private final JpaQuestionRepository questionRepository;

public Long create(final CreateQuestionDto questionDto) {
final User questioner = userRepository.findByIdAndDeletedIsFalse(questionDto.userId())
.orElseThrow(() -> new UserNotFoundException("해당 사용자를 찾을 수 없습니다."));
final Auction auction = auctionRepository.findById(questionDto.auctionId())
.orElseThrow(() -> new AuctionNotFoundException("해당 경매를 찾을 수 없습니다."));
checkInvalidAuction(auction);
Copy link
Collaborator

Choose a reason for hiding this comment

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

선택

여기 개행..?

checkInvalidQuestioner(auction, questioner);

final Question question = questionDto.toEntity(auction, questioner);

return questionRepository.save(question)
.getId();
}

private void checkInvalidAuction(final Auction auction) {
final LocalDateTime now = LocalDateTime.now();

if (auction.isDeleted()) {
throw new InvalidAuctionToAskQuestionException("삭제된 경매입니다.");
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

질문

DB에서 조회할 때 처음부터 삭제되지 않은 경매만 조회할 수 있을 것 같은데, 조건 없이 조회해 서비스 단에서 검증해주신 이유가 있으실까요?
서비스 사용자가 봤을 때에는 삭제된 경매는 보이지 않을 것이기 때문에 바로 삭제되지 않은 Auction만 찾으면 될 것 같아서 질문드립니다

Copy link
Collaborator

Choose a reason for hiding this comment

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

질문

저도 궁금합니다.

Copy link
Member Author

Choose a reason for hiding this comment

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

다른 기능 구현 시 이와 동일하게 진행했기 때문입니다.
하지만, 해당 방법이 더 적절할 것 같아 현재 로직에서는 말씀해 주신 내용과 같이 수정한 후 따로 이슈를 만들어 다른 부분에서도 해당 로직으로 수정할 수 있도록 하겠습니다.

if (auction.isClosed(now)) {
throw new InvalidAuctionToAskQuestionException("이미 종료된 경매입니다.");
}
}

private void checkInvalidQuestioner(final Auction auction, final User questioner) {
if (auction.isOwner(questioner)) {
throw new InvalidQuestionerException("경매 등록자는 질문할 수 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.ddang.ddang.questionandanswer.application.dto;

import com.ddang.ddang.questionandanswer.domain.Answer;
import com.ddang.ddang.questionandanswer.presentation.dto.CreateAnswerRequest;

public record CreateAnswerDto(Long questionId, String content, Long userId) {

public static CreateAnswerDto of(final Long questionId, final CreateAnswerRequest answerRequest, final Long userId) {
return new CreateAnswerDto(questionId, answerRequest.content(), userId);
}

public Answer toEntity() {
return new Answer(content);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.ddang.ddang.questionandanswer.application.dto;

import com.ddang.ddang.auction.domain.Auction;
import com.ddang.ddang.questionandanswer.domain.Question;
import com.ddang.ddang.questionandanswer.presentation.dto.CreateQuestionRequest;
import com.ddang.ddang.user.domain.User;

public record CreateQuestionDto(Long auctionId, String content, Long userId) {

public static CreateQuestionDto of(final CreateQuestionRequest questionRequest, final Long userId) {
return new CreateQuestionDto(questionRequest.auctionId(), questionRequest.content(), userId);
}

public Question toEntity(final Auction auction, final User questioner) {
return new Question(auction, questioner, content);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.ddang.ddang.questionandanswer.application.exception;

public class AlreadyAnsweredException extends IllegalArgumentException {

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

public class InvalidAnswererException extends IllegalArgumentException {

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

public class InvalidAuctionToAskQuestionException extends IllegalArgumentException {

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

public class InvalidQuestionerException extends IllegalArgumentException {

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

import com.ddang.ddang.common.entity.BaseCreateTimeEntity;
import jakarta.persistence.Column;
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.OneToOne;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@EqualsAndHashCode(of = "id")
@ToString(of = {"id", "content"})
public class Answer extends BaseCreateTimeEntity {

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

@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "question_id", foreignKey = @ForeignKey(name = "fk_answer_question"))
private Question question;

@Column(columnDefinition = "text")
private String content;

public Answer(final String content) {
this.content = content;
}

public void initQuestion(final Question question) {
this.question = question;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.ddang.ddang.questionandanswer.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.Column;
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 jakarta.persistence.OneToOne;
import lombok.AccessLevel;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@EqualsAndHashCode(of = "id")
@ToString(of = {"id", "content"})
public class Question extends BaseCreateTimeEntity {

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

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "wrtier_id", foreignKey = @ForeignKey(name = "fk_question_writer"))
private User writer;

@Column(columnDefinition = "text")
private String content;

@OneToOne(mappedBy = "question")
private Answer answer;

public Question(final Auction auction, final User writer, final String content) {
this.auction = auction;
this.writer = writer;
this.content = content;
}

public void addAnswer(final Answer answer) {
this.answer = answer;
answer.initQuestion(this);
}

public boolean isAnsweringAllowed(final User user) {
return auction.isOwner(user);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ddang.ddang.questionandanswer.infrastructure;

import com.ddang.ddang.questionandanswer.domain.Answer;
import org.springframework.data.jpa.repository.JpaRepository;

public interface JpaAnswerRepository extends JpaRepository<Answer, Long> {

boolean existsByQuestionId(Long questionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.ddang.ddang.questionandanswer.infrastructure;

import com.ddang.ddang.questionandanswer.domain.Question;
import org.springframework.data.jpa.repository.JpaRepository;

public interface JpaQuestionRepository extends JpaRepository<Question, Long> {
}
Loading