diff --git a/plu-api/src/main/kotlin/com/th/plu/api/config/WebConfig.kt b/plu-api/src/main/kotlin/com/th/plu/api/config/WebConfig.kt index 95c6174..fc68438 100644 --- a/plu-api/src/main/kotlin/com/th/plu/api/config/WebConfig.kt +++ b/plu-api/src/main/kotlin/com/th/plu/api/config/WebConfig.kt @@ -1,8 +1,10 @@ package com.th.plu.api.config +import com.th.plu.api.config.converter.YearMonthConverter import com.th.plu.api.config.interceptor.AuthInterceptor import com.th.plu.api.config.resolver.MemberIdResolver import org.springframework.context.annotation.Configuration +import org.springframework.format.FormatterRegistry import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.servlet.config.annotation.InterceptorRegistry import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @@ -10,7 +12,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer @Configuration class WebConfig( private val authInterceptor: AuthInterceptor, - private val memberIdResolver: MemberIdResolver + private val memberIdResolver: MemberIdResolver, + private val yearMonthConverter: YearMonthConverter, ) : WebMvcConfigurer { override fun addInterceptors(registry: InterceptorRegistry) { @@ -21,4 +24,7 @@ class WebConfig( resolvers.add(memberIdResolver) } + override fun addFormatters(registry: FormatterRegistry) { + registry.addConverter(yearMonthConverter) + } } diff --git a/plu-api/src/main/kotlin/com/th/plu/api/config/converter/YearMonthConverter.kt b/plu-api/src/main/kotlin/com/th/plu/api/config/converter/YearMonthConverter.kt new file mode 100644 index 0000000..84697ed --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/config/converter/YearMonthConverter.kt @@ -0,0 +1,12 @@ +package com.th.plu.api.config.converter + +import org.springframework.core.convert.converter.Converter +import org.springframework.stereotype.Component +import java.time.YearMonth +import java.time.format.DateTimeFormatter + +@Component +class YearMonthConverter : Converter { + override fun convert(source: String): YearMonth = + YearMonth.parse(source, DateTimeFormatter.ofPattern("yyyyMM")) +} \ No newline at end of file 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 index a702bd9..622ed96 100644 --- 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 @@ -2,21 +2,38 @@ 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.WritingAnswerRequestDto +import com.th.plu.api.controller.answer.dto.WritingAnswerResponseDto import com.th.plu.api.controller.answer.dto.response.AnswerInfoResponse import com.th.plu.api.controller.answer.dto.response.EveryAnswerInfoResponse -import com.th.plu.api.service.answer.AnswerService +import com.th.plu.api.controller.answer.dto.toAnswerWriting +import com.th.plu.api.controller.answer.dto.toWritingAnswerResponse import com.th.plu.common.dto.response.ApiResponse +import com.th.plu.api.service.answer.AnswerService import com.th.plu.domain.domain.answer.dto.EveryAnswerRetrieveResponses import io.swagger.v3.oas.annotations.Operation import io.swagger.v3.oas.annotations.tags.Tag import org.springframework.web.bind.annotation.* -@Tag(name = "Answer") + +@Tag(name = "Answers") @RestController @RequestMapping("/api") class AnswerController( - private val answerService: AnswerService + private val answerService: AnswerService, ) { + @Auth + @Operation(summary = "오늘의 질문 답변하기") + @PostMapping("/v1/answers/{questionId}") + fun writeAnswer( + @MemberId memberId: Long, + @PathVariable("questionId") questionId: Long, + @RequestBody writingAnswerRequestDto: WritingAnswerRequestDto, + ): ApiResponse = + answerService.writeAnswer(memberId, questionId, toAnswerWriting(writingAnswerRequestDto)).let { + ApiResponse.success(toWritingAnswerResponse(it)) + } + @Auth @Operation(summary = "[인증] 질문 답변 조회") @GetMapping("/v1/answer/{answerId}") @@ -52,8 +69,8 @@ class AnswerController( @Operation(summary = "[인증] 모두의 답변 조회(무한 스크롤)") @GetMapping("/v1/answers") fun paginateAnswersByCursor( - @RequestParam(defaultValue = Long.MAX_VALUE.toString()) lastAnswerId: Long, - @RequestParam(defaultValue = "10") pageSize: Long, + @RequestParam(defaultValue = Long.MAX_VALUE.toString()) lastAnswerId: Long, + @RequestParam(defaultValue = "10") pageSize: Long, ): ApiResponse { return ApiResponse.success(answerService.findEveryAnswersWithCursor(lastAnswerId, pageSize)) } @@ -62,8 +79,8 @@ class AnswerController( @Operation(summary = "[인증] 모두의 답변 조회(좋아요 TopN)") @GetMapping("/v1/answers/popular") fun getAnswersAboutLikeTopN( - @RequestParam(defaultValue = "10") getCount: Long, + @RequestParam(defaultValue = "10") getCount: Long, ): ApiResponse { return ApiResponse.success(answerService.findEveryAnswersLikeTopN(getCount)) } -} +} \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/AnswerResponseDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/AnswerResponseDto.kt new file mode 100644 index 0000000..1db83bb --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/AnswerResponseDto.kt @@ -0,0 +1,6 @@ +package com.th.plu.api.controller.answer.dto + +data class AnswerResponseDto( + val id: Long, + val body: String, +) \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/ReactionResponseDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/ReactionResponseDto.kt new file mode 100644 index 0000000..a82306c --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/ReactionResponseDto.kt @@ -0,0 +1,5 @@ +package com.th.plu.api.controller.answer.dto + +data class ReactionResponseDto( + val likeCount: Long, +) diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/WritingAnswerRequestDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/WritingAnswerRequestDto.kt new file mode 100644 index 0000000..cd5270c --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/WritingAnswerRequestDto.kt @@ -0,0 +1,17 @@ +package com.th.plu.api.controller.answer.dto + +import com.th.plu.domain.domain.answer.AnswerWriting + +data class WritingAnswerRequestDto( + val body: String, + val settings: AnswerSettingRequestDto, +) + +data class AnswerSettingRequestDto( + val open: Boolean, +) + +fun toAnswerWriting(writingAnswerRequestDto: WritingAnswerRequestDto) = AnswerWriting( + body = writingAnswerRequestDto.body, + open = writingAnswerRequestDto.settings.open, +) \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/WritingAnswerResponseDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/WritingAnswerResponseDto.kt new file mode 100644 index 0000000..5fe6640 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/answer/dto/WritingAnswerResponseDto.kt @@ -0,0 +1,29 @@ +package com.th.plu.api.controller.answer.dto + +import com.th.plu.api.controller.question.dto.QuestionResponseDto +import com.th.plu.domain.domain.answer.WritingAnswerResult + +data class WritingAnswerResponseDto( + val question: QuestionResponseDto, + val answer: AnswerResponseDto, + val reaction: ReactionResponseDto, +) + +internal fun toWritingAnswerResponse(writingAnswerResult: WritingAnswerResult) = WritingAnswerResponseDto( + question = QuestionResponseDto( + id = writingAnswerResult.questionId, + title = writingAnswerResult.questionTitle, + content = writingAnswerResult.questionContent, + exposedAt = writingAnswerResult.questionExposedAt, + elementType = writingAnswerResult.questionElementType, + characterImageUrl = writingAnswerResult.characterImageUrl, + answered = writingAnswerResult.questionAnswered, + ), + answer = AnswerResponseDto( + id = writingAnswerResult.answerId, + body = writingAnswerResult.answerBody, + ), + reaction = ReactionResponseDto( + likeCount = writingAnswerResult.reactionLikeCount, + ) +) \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/question/QuestionController.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/QuestionController.kt new file mode 100644 index 0000000..1d2e133 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/QuestionController.kt @@ -0,0 +1,48 @@ +package com.th.plu.api.controller.question + +import com.th.plu.api.config.interceptor.Auth +import com.th.plu.api.config.resolver.MemberId +import com.th.plu.api.controller.question.dto.* +import com.th.plu.common.dto.response.ApiResponse +import com.th.plu.api.service.question.QuestionService +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.YearMonth + +@Tag(name = "Questions") +@RestController +class QuestionController( + private val questionService: QuestionService, +) { + @Auth + @Operation(summary = "오늘의 질문") + @GetMapping("/api/v1/questions/today") + fun getQuestionToday( + @MemberId memberId: Long, + ): ApiResponse = questionService.getQuestionToday(memberId).let { + ApiResponse.success(toQuestionTodayResponseDto(it)) + } + + @Auth + @Operation(summary = "내가 답변한 질문 월별 조회") + @GetMapping("/api/v1/questions/my") + fun getQuestionsWhatIAnsweredMonthly( + @MemberId memberId: Long, + @RequestParam("yearMonth") @DateTimeFormat(pattern = "yyyyMM") selectedYearMonth: YearMonth, + ): ApiResponse = + questionService.getQuestionsAnsweredMonthly(memberId, selectedYearMonth).let { + ApiResponse.success(toQuestionListResponseDto(it)) + } + + @Auth + @Operation(summary = "답변 기록이 있는 년월 얻기") + @GetMapping("/api/v1/questions/answeredDate") + fun getYearMonthWhenIAnswered( + @MemberId memberId: Long, + ): ApiResponse = + questionService.getYearMonthAnswered(memberId).let { ApiResponse.success(toQuestionAnsweredResponse(it)) } +} \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionAnsweredResponseDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionAnsweredResponseDto.kt new file mode 100644 index 0000000..32c8a98 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionAnsweredResponseDto.kt @@ -0,0 +1,20 @@ +package com.th.plu.api.controller.question.dto + +import java.time.YearMonth + +data class QuestionAnsweredResponseDto( + val year: List, + val yearMonth: List +) + +data class YearMonthResponseDto( + val year: Int, + val month: Int, +) + +internal fun toQuestionAnsweredResponse(yearMonths: Set) = QuestionAnsweredResponseDto( + year = yearMonths.map { it.year }.sortedDescending(), + yearMonth = yearMonths.map { toYearMonthResponse(it) } +) + +internal fun toYearMonthResponse(yearMonth: YearMonth) = YearMonthResponseDto(year = yearMonth.year, month = yearMonth.monthValue) \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionListResponseDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionListResponseDto.kt new file mode 100644 index 0000000..646d80e --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionListResponseDto.kt @@ -0,0 +1,13 @@ +package com.th.plu.api.controller.question.dto + +import com.th.plu.domain.domain.question.QuestionResultDto + +data class QuestionListResponseDto( + val answerCount: Int, + val questions: List, +) + +internal fun toQuestionListResponseDto(resultList: List): QuestionListResponseDto = QuestionListResponseDto( + answerCount = resultList.size, + questions = resultList.map { toQuestionResponseDto(it) } +) \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionResponseDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionResponseDto.kt new file mode 100644 index 0000000..6a952a4 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionResponseDto.kt @@ -0,0 +1,25 @@ +package com.th.plu.api.controller.question.dto + +import com.th.plu.domain.domain.question.ElementType +import com.th.plu.domain.domain.question.QuestionResultDto +import java.time.LocalDateTime + +data class QuestionResponseDto( + val id: Long, + val title: String, + val content: String, + val exposedAt: LocalDateTime, + val characterImageUrl: String, + val elementType: ElementType, + val answered: Boolean, +) + +internal fun toQuestionResponseDto(result: QuestionResultDto) = QuestionResponseDto( + id = result.questionId, + title = result.title, + content = result.content, + exposedAt = result.exposedAt, + characterImageUrl = result.elementType.characterImageUrl, + elementType = result.elementType, + answered = result.answered, +) \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionTodayResponseDto.kt b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionTodayResponseDto.kt new file mode 100644 index 0000000..77bdcb8 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/controller/question/dto/QuestionTodayResponseDto.kt @@ -0,0 +1,11 @@ +package com.th.plu.api.controller.question.dto + +import com.th.plu.domain.domain.question.QuestionResultDto + +data class QuestionTodayResponseDto( + val question: QuestionResponseDto, +) + +internal fun toQuestionTodayResponseDto(result: QuestionResultDto): QuestionTodayResponseDto = QuestionTodayResponseDto( + question = toQuestionResponseDto(result) +) \ 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 index 25d3959..2fc5be1 100644 --- 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 @@ -3,27 +3,35 @@ package com.th.plu.api.service.answer import com.th.plu.api.controller.answer.dto.response.AnswerInfoResponse import com.th.plu.api.controller.answer.dto.response.EveryAnswerInfoResponse import com.th.plu.api.service.like.LikeValidator +import com.th.plu.common.exception.code.ErrorCode +import com.th.plu.common.exception.model.ConflictException +import com.th.plu.domain.domain.answer.AnswerRegister +import com.th.plu.domain.domain.answer.AnswerWriting +import com.th.plu.domain.domain.answer.WritingAnswerResult import com.th.plu.domain.domain.answer.dto.EveryAnswerRetrieveResponses import com.th.plu.domain.domain.answer.explorer.AnswerExplorer -import com.th.plu.domain.domain.answer.explorer.QuestionExplorer import com.th.plu.domain.domain.answer.repository.AnswerRepository import com.th.plu.domain.domain.like.Like import com.th.plu.domain.domain.like.explorer.LikeExplorer import com.th.plu.domain.domain.like.repository.LikeRepository import com.th.plu.domain.domain.member.explorer.MemberExplorer +import com.th.plu.domain.domain.question.QuestionExplorer +import com.th.plu.domain.isUniqueError +import org.springframework.dao.DataIntegrityViolationException import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @Service class AnswerService( - private val answerExplorer: AnswerExplorer, - private val answerRepository: AnswerRepository, - private val answerValidator: AnswerValidator, - private val likeRepository: LikeRepository, - private val likeExplorer: LikeExplorer, - private val likeValidator: LikeValidator, - private val memberExplorer: MemberExplorer, - private val questionExplorer: QuestionExplorer + private val answerExplorer: AnswerExplorer, + private val answerRegister: AnswerRegister, + private val answerRepository: AnswerRepository, + private val answerValidator: AnswerValidator, + private val likeRepository: LikeRepository, + private val likeExplorer: LikeExplorer, + private val likeValidator: LikeValidator, + private val memberExplorer: MemberExplorer, + private val questionExplorer: QuestionExplorer, ) { @Transactional(readOnly = true) fun findAnswerInfoById(answerId: Long, memberId: Long): AnswerInfoResponse { @@ -31,7 +39,7 @@ class AnswerService( if (!answer.isPublic) { answerValidator.validateIsMemberOwnerOfAnswer(answerId, memberId) } - val question = questionExplorer.findQuestionById(answer.getQuestionId()) + val question = questionExplorer.findQuestion(answer.questionId) return AnswerInfoResponse.of(question, answer) } @@ -57,22 +65,51 @@ class AnswerService( @Transactional(readOnly = true) fun findEveryAnswersWithCursor(lastAnswerId: Long, pageSize: Long): EveryAnswerRetrieveResponses { val todayQuestionId = questionExplorer.findTodayQuestion().id - val answers = answerRepository.findEveryAnswersWithCursorAndPageSize(todayQuestionId!!, lastAnswerId, pageSize) + val answers = answerRepository.findEveryAnswersWithCursorAndPageSize(todayQuestionId, lastAnswerId, pageSize) return EveryAnswerRetrieveResponses(answers) } @Transactional(readOnly = true) fun findEveryAnswerInfo(): EveryAnswerInfoResponse { val todayQuestion = questionExplorer.findTodayQuestion() - val answerCount = answerRepository.findPublicAnswersCountByQuestionId(todayQuestion.id!!) + val answerCount = answerRepository.findPublicAnswersCountByQuestionId(todayQuestion.id) return EveryAnswerInfoResponse.of(todayQuestion, answerCount) } fun findEveryAnswersLikeTopN(getCount: Long): EveryAnswerRetrieveResponses { val todayQuestion = questionExplorer.findTodayQuestion() - val answers = answerRepository.findPublicAnswersLikeTopN(todayQuestion.id!!, getCount) + val answers = answerRepository.findPublicAnswersLikeTopN(todayQuestion.id, getCount) return EveryAnswerRetrieveResponses(answers) } -} + + @Transactional + fun writeAnswer(memberId: Long, questionId: Long, answerWriting: AnswerWriting): WritingAnswerResult { + // validate not found + val memberEntity = memberExplorer.findMemberById(memberId) + val questionEntity = questionExplorer.findQuestion(questionId) + + return try { + answerRegister.registerAnswer(memberEntity, questionEntity, answerWriting.body, answerWriting.open).let { + WritingAnswerResult( + questionId = questionEntity.id, + questionTitle = questionEntity.title, + questionContent = questionEntity.content, + questionExposedAt = questionEntity.exposedAt, + questionElementType = questionEntity.elementType, + questionAnswered = true, + answerId = it.id, + answerBody = it.content, + reactionLikeCount = 0 // 최초 생성시는 0 + ) + } + } catch (e: DataIntegrityViolationException) { + if (e.isUniqueError()) { + throw ConflictException(ErrorCode.CONFLICT_ANSWER_EXCEPTION, "이미 답변한 질문에 답변을 요청했습니다.") + } else { + throw e + } + } + } +} \ No newline at end of file diff --git a/plu-api/src/main/kotlin/com/th/plu/api/service/question/QuestionService.kt b/plu-api/src/main/kotlin/com/th/plu/api/service/question/QuestionService.kt new file mode 100644 index 0000000..869b321 --- /dev/null +++ b/plu-api/src/main/kotlin/com/th/plu/api/service/question/QuestionService.kt @@ -0,0 +1,52 @@ +package com.th.plu.api.service.question + +import com.th.plu.domain.domain.answer.AnswerExplorer +import com.th.plu.domain.domain.question.QuestionExplorer +import com.th.plu.domain.domain.question.QuestionResultDto +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.YearMonth + +@Service +class QuestionService( + private val questionExplorer: QuestionExplorer, + private val answerExplorer: AnswerExplorer, +) { + @Transactional(readOnly = true) + fun getQuestionToday(memberId: Long): QuestionResultDto { + val today = LocalDateTime.now() + + return questionExplorer.findQuestion(today).let { todayQuestion -> + val answered = answerExplorer.hasAnswered(memberId, todayQuestion.id) + + QuestionResultDto( + questionId = todayQuestion.id, + title = todayQuestion.title, + content = todayQuestion.content, + elementType = todayQuestion.elementType, + exposedAt = todayQuestion.exposedAt, + answered = answered, + ) + } + } + + @Transactional(readOnly = true) + fun getQuestionsAnsweredMonthly(memberId: Long, selectedYearMonth: YearMonth): List = + questionExplorer.findMyQuestionsMonthly(memberId, selectedYearMonth) + .map { + QuestionResultDto( + questionId = it.id, + title = it.title, + content = it.content, + elementType = it.elementType, + exposedAt = it.exposedAt, + answered = true, // 내가 한 답변들 + ) + } + .sortedByDescending { it.exposedAt } // 최신순 조회 + + @Transactional(readOnly = true) + fun getYearMonthAnswered(memberId: Long): Set = + questionExplorer.findAnsweredYearMonth(memberId) +} \ 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 bd603b9..5d2ee5a 100644 --- a/plu-api/src/main/resources/sql/schema.sql +++ b/plu-api/src/main/resources/sql/schema.sql @@ -33,8 +33,10 @@ CREATE TABLE `questions` `question_date` datetime NOT NULL, `question_title` varchar(100) NOT NULL, `question_content` varchar(300) NOT NULL, + `exposed_at` datetime NOT NULL, `created_at` datetime NOT NULL, - `modified_at` datetime NOT NULL + `modified_at` datetime NOT NULL, + constraint unique ink01_questions(exposed_at) ); CREATE TABLE `answers` 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 22399d3..0a22e92 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 @@ -30,7 +30,7 @@ enum class ErrorCode(val code: String, val message: String) { NOT_FOUND_ARTICLE_EXCEPTION("N005", "삭제되었거나 존재하지 않는 아티클입니다."), NOT_FOUND_ARTICLE_IN_WEEK_AND_DAY_EXCEPTION("N006", "해당 주차 일차에 해당하는 아티클이 존재하지 않습니다."), NOT_FOUND_ENDPOINT_EXCEPTION("N007", "존재하지 않는 엔드포인트입니다."), - NOT_FOUND_ONBOARDING_EXCEPTION("N007", "존재하지 않는 엔드포인트입니다."), + NOT_FOUND_ONBOARDING_EXCEPTION("N008", "존재하지 않는 엔드포인트입니다."), // Conflict Exception CONFLICT_EXCEPTION("C001", "이미 존재합니다."), @@ -38,9 +38,11 @@ enum class ErrorCode(val code: String, val message: String) { CONFLICT_LIKE_EXCEPTION("C003", "이미 해당 답변에 대한 공감이 되어있습니다."), CONFLICT_BOOKMARK_EXCEPTION("C003", "요청과 동일한 북마크 상태 입니다."), CONFLICT_NICKNAME_EXCEPTION("C004", "이미 사용 중인 닉네임 입니다."), + CONFLICT_ANSWER_EXCEPTION("C005", "이미 존재한 답변이 있습니다."), // Internal Server Exception INTERNAL_SERVER_EXCEPTION("I001", "서버 내부에서 에러가 발생하였습니다."), + DATA_NOT_READY_EXCEPTION("I002", "질문 데이터가 준비되지 않았습니다."), // Bad Gateway Exception BAD_GATEWAY_EXCEPTION("B001", "외부 연동 중 에러가 발생하였습니다."), diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/ExceptionUtils.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/ExceptionUtils.kt new file mode 100644 index 0000000..6ad4746 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/ExceptionUtils.kt @@ -0,0 +1,8 @@ +package com.th.plu.domain + +import org.hibernate.exception.ConstraintViolationException +import org.springframework.dao.DataIntegrityViolationException +import java.sql.SQLIntegrityConstraintViolationException + +fun DataIntegrityViolationException.isUniqueError() = + ((cause as? ConstraintViolationException)?.sqlException is SQLIntegrityConstraintViolationException) \ No newline at end of file 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 ba5cdbf..1ced913 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 @@ -11,41 +11,54 @@ import lombok.Builder import lombok.NoArgsConstructor -@Table(name = "answers") +@Table( + name = "answers", + uniqueConstraints = [ + UniqueConstraint(name = "idk01_answers", columnNames = ["member_id", "question_id"]) + ] +) @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder(access = AccessLevel.PRIVATE) class Answer( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "answer_id") - var id: Long? = null, + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "answer_id") + private 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 = "member_id", nullable = false) + private var member: Member, - @ManyToOne(fetch = FetchType.LAZY, cascade = [CascadeType.ALL]) - @JoinColumn(name = "question_id", nullable = false) - 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) - var content: String, - - @Column(name = "is_public", nullable = false) - var isPublic: Boolean, - - @OneToMany(mappedBy = "answer", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) - var likes: List = mutableListOf() + @OneToMany(mappedBy = "answer", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) + private var likes: List = mutableListOf(), + content: String, + isPublic: Boolean, ) : BaseEntity() { + val id: Long + get() = _id ?: throw PersistenceException("아직 save 되지 않은 entity 의 id 에대한 접근입니다.") - fun getQuestionId(): Long { - return question.id!! - } + @Column(name = "answer_content", nullable = false) + var content: String = content + private set - fun getLikeCount(): Int { - return likes.size - } + @Column(name = "is_public", nullable = false) + var isPublic: Boolean = isPublic + private set + + val questionId: Long = question.id } + +fun newAnswerInstance(member: Member, question: Question, content: String, isPublic: Boolean) = Answer( + _id = null, + member = member, + question = question, + content = content, + isPublic = isPublic, +) \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerExplorer.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerExplorer.kt new file mode 100644 index 0000000..fd912ba --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerExplorer.kt @@ -0,0 +1,13 @@ +package com.th.plu.domain.domain.answer + +import com.th.plu.domain.domain.answer.repository.AnswerRepository +import org.springframework.stereotype.Component + +@Component +class AnswerExplorer( + private val answerRepository: AnswerRepository, +) { + fun hasAnswered(memberId: Long, questionId: Long): Boolean { + return answerRepository.existsByMemberIdAndQuestionId(memberId, questionId) + } +} \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerRegister.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerRegister.kt new file mode 100644 index 0000000..31e45f3 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerRegister.kt @@ -0,0 +1,15 @@ +package com.th.plu.domain.domain.answer + +import com.th.plu.domain.domain.answer.repository.AnswerRepository +import com.th.plu.domain.domain.member.Member +import com.th.plu.domain.domain.question.Question +import org.springframework.stereotype.Component + +@Component +class AnswerRegister( + private val answerRepository: AnswerRepository, +) { + // may throw unique exception + fun registerAnswer(memberEntity: Member, questionEntity: Question, body: String, answerOpen: Boolean) = + answerRepository.save(newAnswerInstance(memberEntity, questionEntity, body, answerOpen)) +} \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerWriting.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerWriting.kt new file mode 100644 index 0000000..3b8b8cd --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/AnswerWriting.kt @@ -0,0 +1,6 @@ +package com.th.plu.domain.domain.answer + +data class AnswerWriting ( + val body: String, + val open: Boolean, +) \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/WritingAnswerResult.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/WritingAnswerResult.kt new file mode 100644 index 0000000..cfe1cf2 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/WritingAnswerResult.kt @@ -0,0 +1,20 @@ +package com.th.plu.domain.domain.answer + +import com.th.plu.domain.domain.question.ElementType +import java.time.LocalDateTime + +data class WritingAnswerResult( + val questionId: Long, + val questionTitle: String, + val questionContent: String, + val questionExposedAt: LocalDateTime, + val questionElementType: ElementType, + val questionAnswered: Boolean, + + val answerId: Long, + val answerBody: String, + + val reactionLikeCount: Long, +) { + val characterImageUrl = questionElementType.characterImageUrl +} \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryCustom.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryCustom.kt index 16a5d01..5648300 100644 --- a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryCustom.kt +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryCustom.kt @@ -6,6 +6,8 @@ import com.th.plu.domain.domain.answer.dto.EveryAnswerRetrieveResponse interface AnswerRepositoryCustom { fun findAnswerById(id: Long): Answer? + fun existsByMemberIdAndQuestionId(memberId: Long, questionId: Long): Boolean + fun findEveryAnswersWithCursorAndPageSize(questionId: Long, lastAnswerId: Long, pageSize: Long): List fun findPublicAnswersCountByQuestionId(questionId: Long): Long diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryImpl.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryImpl.kt index 553da28..e7dab69 100644 --- a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryImpl.kt +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/answer/repository/AnswerRepositoryImpl.kt @@ -56,4 +56,14 @@ class AnswerRepositoryImpl(private val queryFactory: JPAQueryFactory) : AnswerRe .limit(getCount) .fetch() } + + override fun existsByMemberIdAndQuestionId(memberId: Long, questionId: Long): Boolean { + return queryFactory + .selectFrom(answer) + .where( + answer.member.id.eq(memberId), + answer.question._id.eq(questionId), + ) + .fetchFirst() != null + } } 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 c89eea3..2a49c46 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,13 +1,13 @@ package com.th.plu.domain.domain.question enum class ElementType( - val characterImageUrl: String, - val elementImageUrl: String, - val colorCode: String + val characterImageUrl: String, + val elementImageUrl: String, + val colorCode: String ) { // TODO: 엘리먼트 네이밍 체크 필요 WATER("", "", ""), FIRE("", "", ""), GROUND("", "", ""), - CLOUD("", "", "") + CLOUD("", "", ""), } diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/Question.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/Question.kt index 35a5950..9401306 100644 --- a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/Question.kt +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/Question.kt @@ -3,31 +3,46 @@ package com.th.plu.domain.domain.question import com.th.plu.domain.domain.answer.Answer import com.th.plu.domain.domain.common.BaseEntity import jakarta.persistence.* -import java.time.LocalDate +import java.time.LocalDateTime @Table(name = "questions") @Entity class Question( - @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - @Column(name = "question_id") - val id: Long? = null, - - @Column(name = "element_type", nullable = false, length = 30) - @Enumerated(EnumType.STRING) - var elementType: ElementType, + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "question_id") + private var _id: Long? = null, + + @OneToMany(mappedBy = "question", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) + var answers: List, + + elementType: ElementType, + questionDate: LocalDateTime, + title: String, + content: String, + exposedAt: LocalDateTime, +) : BaseEntity() { + val id: Long + get() = _id ?: throw PersistenceException("아직 save 되지 않은 entity 의 id 에대한 접근입니다.") - @Column(name = "question_date", nullable = false, length = 30) - var questionDate: LocalDate, + @Column(name = "element_type", nullable = false, length = 30) + @Enumerated(EnumType.STRING) + var elementType: ElementType = elementType + private set - @Column(name = "question_title", nullable = false, length = 100) - var title: String, + @Column(name = "question_date", nullable = false, length = 30) + var questionDate: LocalDateTime = questionDate + private set - @Column(name = "question_content", nullable = false, length = 300) - var content: String, + @Column(name = "question_title", nullable = false, length = 100) + var title: String = title + private set - @OneToMany(mappedBy = "question", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true) - var answers: List + @Column(name = "question_content", nullable = false, length = 300) + var content: String = content + private set -) : BaseEntity() { + @Column(name = "exposed_at", nullable = false, unique = true) + var exposedAt: LocalDateTime = exposedAt + private set } diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/QuestionExplorer.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/QuestionExplorer.kt new file mode 100644 index 0000000..16ff689 --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/QuestionExplorer.kt @@ -0,0 +1,36 @@ +package com.th.plu.domain.domain.question + +import com.th.plu.common.exception.code.ErrorCode +import com.th.plu.common.exception.model.InternalServerException +import com.th.plu.common.exception.model.NotFoundException +import com.th.plu.domain.domain.question.repository.QuestionRepository +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.YearMonth + +@Component +class QuestionExplorer( + private val questionRepository: QuestionRepository, +) { + fun findQuestion(id: Long): Question = + questionRepository.findById(id).orElse(null) + ?: throw NotFoundException(ErrorCode.NOT_FOUND_QUESTION_EXCEPTION, "존재하지 않는 질문 $id 입니다") + + fun findQuestion(date: LocalDateTime): Question = + questionRepository.findByExposedAtOrNull(date) ?: throw InternalServerException( + ErrorCode.DATA_NOT_READY_EXCEPTION, + "($date) 날짜의 질문데이터가 준비되지 않았습니다. " + ) + + fun findMyQuestionsMonthly(memberId: Long, yearMonth: YearMonth): List = + questionRepository.findAllByExposedMonthIn(memberId, yearMonth) + + fun findAnsweredYearMonth(memberId: Long): Set = + questionRepository.findAllExposedAtInAnsweredMonth(memberId) + .map { YearMonth.of(it.year, it.monthValue) } + .toSet() // application 에서 중복 처리중, 500 넘는 warn log 발생시 월별 1건 조회하도록 쿼리 개선 필요! + + fun findTodayQuestion(): Question { + return findQuestion(LocalDateTime.now()) + } +} \ No newline at end of file diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/QuestionResultDto.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/QuestionResultDto.kt new file mode 100644 index 0000000..adb7eae --- /dev/null +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/QuestionResultDto.kt @@ -0,0 +1,12 @@ +package com.th.plu.domain.domain.question + +import java.time.LocalDateTime + +data class QuestionResultDto( + val questionId: Long, + val title: String, + val content: String, + val elementType: ElementType, + val exposedAt: LocalDateTime, + val answered: Boolean, +) diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryCustom.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryCustom.kt index ff7d175..3b4b17e 100644 --- a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryCustom.kt +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryCustom.kt @@ -1,9 +1,18 @@ package com.th.plu.domain.domain.question.repository import com.th.plu.domain.domain.question.Question +import java.time.LocalDateTime +import java.time.YearMonth interface QuestionRepositoryCustom { fun findQuestionById(id: Long): Question? + + fun findByExposedAtOrNull(exposedAt: LocalDateTime): Question? + + fun findAllByExposedMonthIn(memberId: Long, yearMonth: YearMonth): List + + fun findAllExposedAtInAnsweredMonth(memberId: Long): List + fun findTodayQuestion(): Question? } diff --git a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryImpl.kt b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryImpl.kt index 1fd9ceb..4811423 100644 --- a/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryImpl.kt +++ b/plu-domain/src/main/kotlin/com/th/plu/domain/domain/question/repository/QuestionRepositoryImpl.kt @@ -1,27 +1,83 @@ package com.th.plu.domain.domain.question.repository import com.querydsl.jpa.impl.JPAQueryFactory +import com.th.plu.common.Slf4JKotlinLogging.log +import com.th.plu.domain.domain.answer.QAnswer.answer +import com.th.plu.domain.domain.member.QMember.member import com.th.plu.domain.domain.question.QQuestion.question import com.th.plu.domain.domain.question.Question import org.springframework.stereotype.Repository import java.time.LocalDate +import java.time.LocalDateTime +import java.time.YearMonth @Repository class QuestionRepositoryImpl(private val queryFactory: JPAQueryFactory) : QuestionRepositoryCustom { override fun findQuestionById(id: Long): Question? { return queryFactory - .selectFrom(question) - .where(question.id.eq(id)) - .fetchOne() + .selectFrom(question) + .where(question._id.eq(id)) + .fetchOne() + } + + override fun findByExposedAtOrNull(exposedAt: LocalDateTime): Question? { + return queryFactory + .selectFrom(question) + .where(question.exposedAt.eq(exposedAt)) + .fetchOne() + } + + override fun findAllByExposedMonthIn(memberId: Long, yearMonth: YearMonth): List { + return queryFactory + .selectFrom(question) + .innerJoin(member).on(member.id.eq(memberId)) + .innerJoin(answer).on( + answer.member.id.eq(member.id), + answer.question._id.eq(question._id), + ) + .where( + // start (first day of month) + question.exposedAt.goe( + LocalDateTime.of(yearMonth.year, yearMonth.monthValue, 1, 0, 0) + ), + + // end (first day of next month) + question.exposedAt.lt( + yearMonth.plusMonths(1).let { + LocalDateTime.of(it.year, it.monthValue, 1, 0, 0) + }, + ) + ) + .fetch() + } + + // 쿼리 개선 필요, from 절 sub query 필요 -> JPA 외 다른 tool 사용해야함. + // 현재는 작성한 모든 질문의 날짜 조회됨 (full scan, 10000개 썼으면 10000개 read 됨) + // 월별로 1개씩 조회되도록 쿼리 개선 필요! + override fun findAllExposedAtInAnsweredMonth(memberId: Long): List { + return queryFactory + .select(question.exposedAt) + .from(question) + .innerJoin(member).on(member.id.eq(memberId)) + .innerJoin(answer).on( + answer.member.id.eq(member.id), + answer.question._id.eq(question._id), + ) + .fetch() + .also { + if (it.size > 500) { + log.warn { "작성한 질문이 500개 가 넘는 유저가 발생했습니다. 쿼리 개선이 필요합니다!" } + } + } } override fun findTodayQuestion(): Question? { val today = LocalDate.now() return queryFactory - .selectFrom(question) - .where(question.questionDate.eq(today)) - .fetchOne() + .selectFrom(question) + .where(question.questionDate.eq(today)) + .fetchOne() } }