diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/AnswerController.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/AnswerController.kt new file mode 100644 index 0000000..98ac8d4 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/AnswerController.kt @@ -0,0 +1,27 @@ +package com.th.plu.api.controller.answer + +import com.th.plu.api.config.interceptor.Auth +import com.th.plu.api.config.resolver.MemberId +import com.th.plu.api.controller.answer.dto.response.AnswerInfoResponse +import com.th.plu.api.service.answer.AnswerService +import com.th.plu.common.dto.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "Answer") +@RestController +@RequestMapping("/api") +class AnswerController( + private val answerService: AnswerService +) { + @Auth + @Operation(summary = "답변 조회") + @GetMapping("/v1/answer/{answerId}") + fun findAnswerById(@PathVariable answerId: Long, @MemberId memberId: Long): ApiResponse { + return ApiResponse.success(answerService.findAnswerInfoById(answerId, memberId)) + } +} \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/response/AnswerInfoResponse.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/response/AnswerInfoResponse.kt new file mode 100644 index 0000000..8a9fc91 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/response/AnswerInfoResponse.kt @@ -0,0 +1,27 @@ +package com.th.plu.api.controller.answer.dto.response + +import com.th.plu.domain.domain.answer.Answer +import com.th.plu.domain.domain.question.Question +import java.time.LocalDateTime + +data class AnswerInfoResponse( + val questionDate: LocalDateTime, + val questionTitle: String, + val answer: String, + val likeCount: Int, + val elementImageUrl: String, + val colorCode: String +) { + companion object { + fun of(question: Question, answer: Answer): AnswerInfoResponse { + return AnswerInfoResponse( + questionDate = question.modifiedAt, + questionTitle = question.title, + answer = answer.content, + likeCount = answer.getLikeCount(), + elementImageUrl = question.elementType.elementImageUrl, + colorCode = question.elementType.colorCode + ) + } + } +} \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/service/answer/AnswerService.kt b/plu-api/src/main/kotlin/com/th/plu/api/service/answer/AnswerService.kt new file mode 100644 index 0000000..3a6f737 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/service/answer/AnswerService.kt @@ -0,0 +1,25 @@ +package com.th.plu.api.service.answer + +import com.th.plu.api.controller.answer.dto.response.AnswerInfoResponse +import com.th.plu.domain.domain.answer.explorer.AnswerExplorer +import com.th.plu.domain.domain.answer.explorer.QuestionExplorer +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class AnswerService( + private val questionExplorer: QuestionExplorer, + private val answerExplorer: AnswerExplorer, + private val answerValidator: AnswerValidator +) { + @Transactional(readOnly = true) + fun findAnswerInfoById(answerId: Long, memberId: Long): AnswerInfoResponse { + val answer = answerExplorer.findAnswerById(answerId) + if (!answer.isPublic) { + answerValidator.validateIsMemberOwnerOfAnswer(answerId, memberId) + } + val question = questionExplorer.findQuestionById(answer.getQuestionId()) + + return AnswerInfoResponse.of(question, answer) + } +} \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/service/answer/AnswerValidator.kt b/plu-api/src/main/kotlin/com/th/plu/api/service/answer/AnswerValidator.kt new file mode 100644 index 0000000..f9df3b5 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/service/answer/AnswerValidator.kt @@ -0,0 +1,21 @@ +package com.th.plu.api.service.answer + +import com.th.plu.common.exception.code.ErrorCode +import com.th.plu.common.exception.model.ValidationException +import com.th.plu.domain.domain.answer.explorer.AnswerExplorer +import com.th.plu.domain.domain.answer.repository.AnswerRepository +import org.springframework.stereotype.Component + +@Component +class AnswerValidator( + private val answerExplorer: AnswerExplorer, + private val answerRepository: AnswerRepository +) { + fun validateIsMemberOwnerOfAnswer(answerId: Long, memberId: Long) { + val answer = answerExplorer.findAnswerById(answerId) + if (answer.member.id != memberId) { + throw ValidationException(ErrorCode.INVALID_ANSWER_OWNER, + "멤버 (ID: ${memberId})는 답변 (ID: ${answerId})의 답변자가 아니기 때문에 답변 정보에 접근할 수 없습니다.") + } + } +} \ No newline at end of file diff --git a/plu-api/src/main/resources/sql/schema.sql b/plu-api/src/main/resources/sql/schema.sql index bc80224..2d0fe24 100644 --- a/plu-api/src/main/resources/sql/schema.sql +++ b/plu-api/src/main/resources/sql/schema.sql @@ -3,6 +3,7 @@ DROP TABLE IF EXISTS `settings`; DROP TABLE IF EXISTS `questions`; DROP TABLE IF EXISTS `answers`; DROP TABLE IF EXISTS `onboardings`; +DROP TABLE IF EXISTS `likes`; CREATE TABLE `members` @@ -54,3 +55,13 @@ CREATE TABLE `onboardings` `created_at` datetime NOT NULL, `modified_at` datetime NOT NULL ); + +CREATE TABLE `likes` +( + `like_id` bigint auto_increment primary key, + `question_id` bigint NOT NULL, + `answer_id` bigint NOT NULL, + `member_id` bigint NOT NULL, + `created_at` datetime NOT NULL, + `modified_at` datetime NOT NULL +); \ No newline at end of file diff --git a/plu-common/src/main/kotlin/com/th/plu/common/exception/code/ErrorCode.kt b/plu-common/src/main/kotlin/com/th/plu/common/exception/code/ErrorCode.kt index dd62104..cb1ac81 100644 --- a/plu-common/src/main/kotlin/com/th/plu/common/exception/code/ErrorCode.kt +++ b/plu-common/src/main/kotlin/com/th/plu/common/exception/code/ErrorCode.kt @@ -10,6 +10,7 @@ enum class ErrorCode(val code: String, val message: String) { BIND_EXCEPTION("V005", "요청 값을 바인딩하는 과정에서 오류가 발생하였습니다."), METHOD_ARGUMENT_NOT_VALID_EXCEPTION("V006", "요청 값이 검증되지 않은 값 입니다."), INVALID_FORMAT_EXCEPTION("V007", "요청 값이 유효하지 않은 데이터입니다."), + INVALID_ANSWER_OWNER("V008", "질문의 소유자가 아닙니다."), // Unauthorized Exception UNAUTHORIZED_EXCEPTION("U001", "토큰이 만료되었습니다. 다시 로그인 해주세요."), @@ -21,6 +22,8 @@ enum class ErrorCode(val code: String, val message: String) { // NotFound Exception NOT_FOUND_EXCEPTION("N001", "존재하지 않습니다."), NOT_FOUND_MEMBER_EXCEPTION("N002", "탈퇴했거나 존재하지 않는 회원입니다."), + NOT_FOUND_ANSWER_EXCEPTION("N003", "존재하지 않는 답변입니다."), + NOT_FOUND_QUESTION_EXCEPTION("N004", "존재하지 않는 질문입니다."), NOT_FOUND_ARTICLE_CONTENT_EXCEPTION("N003", "아티클의 컨텐츠가 존재하지 않습니다."), NOT_FOUND_CHALLENGE_EXCEPTION("N004", "존재하지 않는 챌린지입니다."), NOT_FOUND_ARTICLE_EXCEPTION("N005", "삭제되었거나 존재하지 않는 아티클입니다."), diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/Answer.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/Answer.kt index 656825d..6a3b829 100644 --- a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/Answer.kt +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/Answer.kt @@ -1,6 +1,7 @@ package com.th.plu.domain.domain.answer import com.th.plu.domain.domain.common.BaseEntity +import com.th.plu.domain.domain.like.Like import com.th.plu.domain.domain.member.Member import com.th.plu.domain.domain.question.Question import jakarta.persistence.* @@ -17,23 +18,34 @@ import lombok.NoArgsConstructor @Builder(access = AccessLevel.PRIVATE) class Answer( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "answer_id") - private var id: Long? = null, + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "answer_id") + var id: Long? = null, - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) - @JoinColumn(name = "member_id", nullable = false) - private var member: Member, + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) - @JoinColumn(name = "question_id", nullable = false) - private var question: Question, + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "question_id", nullable = false) + var question: Question, - @Column(name = "answer_content", nullable = false) - private var content: String, + @Column(name = "answer_content", nullable = false) + var content: String, - @Column(name = "is_public", nullable = false) - private var isPublic: Boolean + @Column(name = "is_public", nullable = false) + var isPublic: Boolean, + + @OneToMany(mappedBy = "answer", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) + var likes: List = mutableListOf() ) : BaseEntity() { + + fun getLikeCount(): Int { + return likes.size + } + + fun getQuestionId(): Long { + return question.id!! + } } diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/explorer/AnswerExplorer.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/explorer/AnswerExplorer.kt new file mode 100644 index 0000000..ac48469 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/explorer/AnswerExplorer.kt @@ -0,0 +1,17 @@ +package com.th.plu.domain.domain.answer.explorer + +import com.th.plu.common.exception.code.ErrorCode +import com.th.plu.common.exception.model.NotFoundException +import com.th.plu.domain.domain.answer.Answer +import com.th.plu.domain.domain.answer.repository.AnswerRepository +import org.springframework.stereotype.Component + +@Component +class AnswerExplorer( + private val answerRepository: AnswerRepository +) { + fun findAnswerById(id: Long): Answer { + return answerRepository.findAnswerById(id) + ?: throw NotFoundException(ErrorCode.NOT_FOUND_ANSWER_EXCEPTION, "존재하지 않는 답변(ID: $id) 입니다") + } +} \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/Like.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/Like.kt new file mode 100644 index 0000000..e7097ea --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/Like.kt @@ -0,0 +1,43 @@ +package com.th.plu.domain.domain.like + +import com.th.plu.domain.domain.answer.Answer +import com.th.plu.domain.domain.common.BaseEntity +import com.th.plu.domain.domain.member.Member +import com.th.plu.domain.domain.question.Question +import jakarta.persistence.* + +@Table(name = "likes") +@Entity +class Like( + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "like_id") + var id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "member_id", nullable = false) + var member: Member, + + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "answer_id", nullable = false) + var answer: Answer, + + @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) + @JoinColumn(name = "question_id", nullable = false) + var question: Question + +) : BaseEntity() { + + companion object { + fun newInstance( + member: Member, answer: Answer, question: Question + ): Like { + return Like( + member = member, + answer = answer, + question = question + ) + } + } +} \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepository.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepository.kt new file mode 100644 index 0000000..fd88cc9 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepository.kt @@ -0,0 +1,7 @@ +package com.th.plu.domain.domain.like.repository + +import com.th.plu.domain.domain.member.Member +import org.springframework.data.jpa.repository.JpaRepository + +interface LikeRepository : JpaRepository, LikeRepositoryCustom { +} diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepositoryCustom.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepositoryCustom.kt new file mode 100644 index 0000000..36da531 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepositoryCustom.kt @@ -0,0 +1,8 @@ +package com.th.plu.domain.domain.like.repository + +import com.th.plu.domain.domain.like.Like + +interface LikeRepositoryCustom { + + fun findLikeById(id: Long): Like? +} diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepositoryImpl.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepositoryImpl.kt new file mode 100644 index 0000000..2f6042c --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/like/repository/LikeRepositoryImpl.kt @@ -0,0 +1,16 @@ +package com.th.plu.domain.domain.like.repository + +import com.querydsl.jpa.impl.JPAQueryFactory +import com.th.plu.domain.domain.like.Like +import com.th.plu.domain.domain.like.QLike.like +import org.springframework.stereotype.Repository + +@Repository +class LikeRepositoryImpl(private val queryFactory: JPAQueryFactory) : LikeRepositoryCustom { + override fun findLikeById(id: Long): Like? { + return queryFactory + .selectFrom(like) + .where(like.id.eq(id)) + .fetchOne(); + } +} diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/ElementType.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/ElementType.kt index b913698..c89eea3 100644 --- a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/ElementType.kt +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/ElementType.kt @@ -1,9 +1,9 @@ package com.th.plu.domain.domain.question enum class ElementType( - private val characterImageUrl: String, - private val elementImageUrl: String, - private val colorCode: String + val characterImageUrl: String, + val elementImageUrl: String, + val colorCode: String ) { // TODO: 엘리먼트 네이밍 체크 필요 WATER("", "", ""), diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/explorer/QuestionExplorer.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/explorer/QuestionExplorer.kt new file mode 100644 index 0000000..2821e80 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/explorer/QuestionExplorer.kt @@ -0,0 +1,17 @@ +package com.th.plu.domain.domain.answer.explorer + +import com.th.plu.common.exception.code.ErrorCode +import com.th.plu.common.exception.model.NotFoundException +import com.th.plu.domain.domain.question.Question +import com.th.plu.domain.domain.question.repository.QuestionRepository +import org.springframework.stereotype.Component + +@Component +class QuestionExplorer( + private val questionRepository: QuestionRepository +) { + fun findQuestionById(id: Long): Question { + return questionRepository.findQuestionById(id) + ?: throw NotFoundException(ErrorCode.NOT_FOUND_QUESTION_EXCEPTION, "존재하지 않는 질문(ID: $id) 입니다") + } +} \ No newline at end of file