diff --git a/.editorconfig b/.editorconfig index 0dfc287..4ab7f15 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,4 +9,6 @@ trim_trailing_whitespace = true insert_final_newline = true max_line_length = 120 tab_width = 4 -disabled_rules = no-wildcard-imports, import-ordering, comment-spacing \ No newline at end of file + +[*.{kt,kts}] +ij_kotlin_packages_to_use_import_on_demand = unset \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 8b96dce..445430f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,6 +7,7 @@ plugins { kotlin("jvm") version "1.9.24" kotlin("plugin.spring") version "1.9.24" id("jacoco") + id("org.jetbrains.dokka") version "1.9.20" // for querydsl kotlin("kapt") version "1.9.24" @@ -67,6 +68,7 @@ dependencies { testImplementation("com.navercorp.fixturemonkey:fixture-monkey-kotlin:1.0.25") implementation("org.bouncycastle:bcpkix-jdk18on:1.75") implementation("com.nimbusds:nimbus-jose-jwt:9.12") + implementation("io.github.oshai:kotlin-logging-jvm:5.1.4") // querydsl implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..3c38a67 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +IS_GREEN=$(docker ps | grep green) +DEFAULT_CONF=" /etc/nginx/nginx.conf" + +if [ -z $IS_GREEN ];then + + echo "### GREEN => BLUE ###" + + echo "1. get blue image" + docker compose pull blue + + echo "2. blue container up" + docker compose up -d blue + + while [ 1 = 1 ]; do + echo "3. blue health check..." + sleep 3 + + REQUEST=$(curl ${server.address}) + if [ -n "$REQUEST" ]; then + echo "health check success" + break ; + fi + done; + + echo "4. reload nginx" + sudo cp /etc/nginx/nginx.blue.conf /etc/nginx/nginx.conf + sudo nginx -s rel + + echo "5. green container down" + docker compose stop green +else + echo "### BLUE => GREEN ###" + + echo "1. get green image" + docker compose pull green + + echo "2. green container up" diff --git a/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt b/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt index 1206a8a..803fafd 100644 --- a/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt +++ b/src/main/kotlin/com/swm_standard/phote/common/resolver/memberId/MemberIdResolver.kt @@ -9,7 +9,7 @@ import org.springframework.web.bind.support.WebDataBinderFactory import org.springframework.web.context.request.NativeWebRequest import org.springframework.web.method.support.HandlerMethodArgumentResolver import org.springframework.web.method.support.ModelAndViewContainer -import java.util.* +import java.util.UUID @Component class MemberIdResolver( diff --git a/src/main/kotlin/com/swm_standard/phote/controller/ExamController.kt b/src/main/kotlin/com/swm_standard/phote/controller/ExamController.kt index 1cd9db7..31b4ca3 100644 --- a/src/main/kotlin/com/swm_standard/phote/controller/ExamController.kt +++ b/src/main/kotlin/com/swm_standard/phote/controller/ExamController.kt @@ -7,11 +7,11 @@ import com.swm_standard.phote.dto.CreateSharedExamResponse import com.swm_standard.phote.dto.GradeExamRequest import com.swm_standard.phote.dto.GradeExamResponse import com.swm_standard.phote.dto.ReadAllSharedExamsResponse -import com.swm_standard.phote.dto.ReadSharedExamInfoResponse import com.swm_standard.phote.dto.ReadExamHistoryDetailResponse import com.swm_standard.phote.dto.ReadExamHistoryListResponse import com.swm_standard.phote.dto.ReadExamResultDetailResponse import com.swm_standard.phote.dto.ReadExamResultsResponse +import com.swm_standard.phote.dto.ReadSharedExamInfoResponse import com.swm_standard.phote.dto.RegradeExamRequest import com.swm_standard.phote.dto.RegradeExamResponse import com.swm_standard.phote.service.ExamService @@ -29,12 +29,20 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import java.util.UUID +/** + * Exam 관련 컨트롤러 클래스 + */ @RestController @RequestMapping("/api") @Tag(name = "Exam", description = "Exam API Document") class ExamController( private val examService: ExamService, ) { + /** + * 시험 기록 상세 조회 기능 + * @param id 시험 id + * @return 시험 기록 상세 정보 + */ @Operation(summary = "readExamHistoryDetail", description = "문제풀이 기록 상세조회") @SecurityRequirement(name = "bearer Auth") @GetMapping("/exam/{id}") @@ -82,7 +90,7 @@ class ExamController( @Operation(summary = "gradeExam", description = "문제풀이 제출 및 채점") @SecurityRequirement(name = "bearer Auth") @PostMapping("/exam") - fun gradeExam( + suspend fun gradeExam( @Valid @RequestBody request: GradeExamRequest, @Parameter(hidden = true) @MemberId memberId: UUID, ): BaseResponse { diff --git a/src/main/kotlin/com/swm_standard/phote/entity/Answer.kt b/src/main/kotlin/com/swm_standard/phote/entity/Answer.kt index d8582dd..6bea136 100644 --- a/src/main/kotlin/com/swm_standard/phote/entity/Answer.kt +++ b/src/main/kotlin/com/swm_standard/phote/entity/Answer.kt @@ -41,7 +41,8 @@ data class Answer( ) } - fun checkMultipleAnswer() { + fun checkMultipleAnswer(): Boolean { isCorrect = submittedAnswer == question?.answer + return isCorrect } } diff --git a/src/main/kotlin/com/swm_standard/phote/external/aws/S3Service.kt b/src/main/kotlin/com/swm_standard/phote/external/aws/S3Service.kt index 0bd20b4..3cf916f 100644 --- a/src/main/kotlin/com/swm_standard/phote/external/aws/S3Service.kt +++ b/src/main/kotlin/com/swm_standard/phote/external/aws/S3Service.kt @@ -6,7 +6,7 @@ import com.swm_standard.phote.common.exception.BadRequestException import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.multipart.MultipartFile -import java.util.* +import java.util.UUID @Component class S3Service( diff --git a/src/main/kotlin/com/swm_standard/phote/repository/MemberRepository.kt b/src/main/kotlin/com/swm_standard/phote/repository/MemberRepository.kt index f3458c4..71c8e62 100644 --- a/src/main/kotlin/com/swm_standard/phote/repository/MemberRepository.kt +++ b/src/main/kotlin/com/swm_standard/phote/repository/MemberRepository.kt @@ -3,7 +3,7 @@ package com.swm_standard.phote.repository import com.swm_standard.phote.entity.Member import org.springframework.data.jpa.repository.JpaRepository import org.springframework.stereotype.Repository -import java.util.* +import java.util.UUID @Repository interface MemberRepository : JpaRepository { diff --git a/src/main/kotlin/com/swm_standard/phote/service/ExamService.kt b/src/main/kotlin/com/swm_standard/phote/service/ExamService.kt index f2fba08..e17488a 100644 --- a/src/main/kotlin/com/swm_standard/phote/service/ExamService.kt +++ b/src/main/kotlin/com/swm_standard/phote/service/ExamService.kt @@ -27,6 +27,7 @@ import com.swm_standard.phote.entity.ExamResult import com.swm_standard.phote.entity.ExamStatus import com.swm_standard.phote.entity.Member import com.swm_standard.phote.entity.ParticipationType +import com.swm_standard.phote.entity.Question import com.swm_standard.phote.entity.SharedExam import com.swm_standard.phote.entity.Workbook import com.swm_standard.phote.repository.AnswerRepository @@ -36,15 +37,17 @@ import com.swm_standard.phote.repository.examrepository.ExamRepository import com.swm_standard.phote.repository.examresultrepository.ExamResultRepository import com.swm_standard.phote.repository.questionrepository.QuestionRepository import com.swm_standard.phote.repository.workbookrepository.WorkbookRepository -import kotlinx.coroutines.CoroutineScope +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.launch +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.withContext import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import org.springframework.web.client.RestTemplate +import java.lang.System.currentTimeMillis import java.time.LocalDateTime import java.util.UUID import kotlin.jvm.optionals.getOrElse @@ -58,6 +61,8 @@ private fun SharedExam.checkStatus(): ExamStatus = ExamStatus.IN_PROGRESS } +private val logger = KotlinLogging.logger {} + @Service @Transactional(readOnly = true) class ExamService( @@ -193,84 +198,40 @@ class ExamService( } @Transactional - fun gradeExam( + suspend fun gradeExam( request: GradeExamRequest, memberId: UUID, - ): GradeExamResponse { - val member = findMember(memberId) - - val exam = - if (request.workbookId != null) { - val workbook = findWorkbook(request.workbookId) - isWorkbookOwner(workbook, memberId) - - examRepository.save( - Exam - .createExam( - member, - workbook, - examRepository.findMaxSequenceByWorkbookId(workbook) + 1, - ), - ) - } else { - ( - examRepository - .findById( - checkNotNull(request.examId), - ).orElseThrow { NotFoundException(fieldName = "exam") } - as SharedExam - ).apply { - validateSubmissionTime() - increaseExamineeCount() - } - } + ): GradeExamResponse = withContext(Dispatchers.IO + CoroutineName("gradeExamV2")) { + val startTime = currentTimeMillis() + logger.info { "[${coroutineContext[CoroutineName.Key]}] : gradeExam 시작" } - val examResult = - examResultRepository.save( - ExamResult.createExamResult( - member = member, - time = request.time, - exam = exam, - totalQuantity = request.answers.size, - ), - ) - - var totalCorrect = 0 - - val questions = - questionRepository - .findAllByIdIn(request.answers.map { answer -> answer.questionId }) - .associateBy { it.id } + val member = findMember(memberId) + val exam = getOrCreateExam(request, memberId, member) + val examResult = createExamResult(member, request, exam) - val response = - request.answers.mapIndexed { index: Int, answer: SubmittedAnswerRequest -> - val question = questions.getValue(answer.questionId) + val questions = questionRepository.findAllByIdIn(request.answers.map { it.questionId }) + .associateBy { it.id } - val savingAnswer: Answer = - Answer.createAnswer( - question = question, - submittedAnswer = request.answers[index].submittedAnswer, - examResult = examResult, - sequence = index + 1, - ) + val answerResults = request.answers.mapIndexed { index, answer -> + async { + logger.info { "[${coroutineContext[CoroutineName.Key]}][answer: ${index + 1}] : gradeAnswer 시작" } + gradeAnswer(questions.getValue(answer.questionId), answer, examResult, index) + } + }.awaitAll() + .let { + answerRepository.saveAll(it) + } - CoroutineScope(Dispatchers.IO).launch { - if (savingAnswer.submittedAnswer == null) { - savingAnswer.isCorrect = false - } else { - when (question.category) { - Category.MULTIPLE -> savingAnswer.checkMultipleAnswer() - Category.ESSAY -> - savingAnswer.isCorrect = - async { gradeByChatGpt(savingAnswer) }.await() - } - } - if (savingAnswer.isCorrect) { - totalCorrect += 1 - } - } - val savedAnswer = answerRepository.save(savingAnswer) + examResult.increaseTotalCorrect(answerResults.count { it.isCorrect }) + examResultRepository.save(examResult) + logger.info { "[${coroutineContext[CoroutineName]}] : gradeExam 종료" } + logger.info { "gradeExam 실행 시간: ${currentTimeMillis() - startTime}ms" } + GradeExamResponse( + examId = exam.id!!, + totalCorrect = examResult.totalCorrect, + questionQuantity = answerResults.size, + answers = answerResults.map { savedAnswer -> AnswerResponse( questionId = savedAnswer.question!!.id, submittedAnswer = savedAnswer.submittedAnswer, @@ -278,26 +239,9 @@ class ExamService( isCorrect = savedAnswer.isCorrect, ) } - - examResult.increaseTotalCorrect(totalCorrect) - - return GradeExamResponse( - examId = exam.id!!, - totalCorrect = examResult.totalCorrect, - questionQuantity = response.size, - answers = response, ) } - private fun isWorkbookOwner( - workbook: Workbook, - memberId: UUID, - ) { - if (workbook.member.id != memberId) { - throw BadRequestException(fieldName = "member", "사용자가 소유한 시험이 아닙니다.") - } - } - @Transactional fun regradeExam( examId: UUID, @@ -404,18 +348,92 @@ class ExamService( NotFoundException(fieldName = "member") } + private suspend fun gradeAnswer( + question: Question, + answer: SubmittedAnswerRequest, + examResult: ExamResult, + index: Int + ): Answer = withContext(Dispatchers.IO) { + val savingAnswer = Answer.createAnswer( + question = question, + submittedAnswer = answer.submittedAnswer, + examResult = examResult, + sequence = index + 1 + ) + savingAnswer.isCorrect = when { + savingAnswer.submittedAnswer == null -> false + question.category == Category.MULTIPLE -> savingAnswer.checkMultipleAnswer() + question.category == Category.ESSAY -> gradeByChatGpt(savingAnswer) + else -> throw BadRequestException(message = "ChatGPT 채점 오류") + } + logger.info { "[CoroutineName(###########)][answer: ${index + 1}] : gradeAnswer 종료" } + + savingAnswer + } + private suspend fun gradeByChatGpt(savingAnswer: Answer): Boolean { val chatGptRequest = ChatGPTRequest(model, savingAnswer.submittedAnswer!!, savingAnswer.question!!.answer) - val chatGPTResponse = - withContext(Dispatchers.IO) { - template.postForObject(url, chatGptRequest, ChatGPTResponse::class.java) - } + val chatGPTResponse = withContext(Dispatchers.IO) { + logger.info { "[${coroutineContext[CoroutineName.Key]}][answer: ${savingAnswer.sequence}] : chatgpt 시작" } + template.postForObject(url, chatGptRequest, ChatGPTResponse::class.java) + } return when (chatGPTResponse!!.choices[0].message.content) { "true" -> true else -> false } } + + private fun createExamResult( + member: Member, + request: GradeExamRequest, + exam: Exam + ) = examResultRepository.save( + ExamResult.createExamResult( + member = member, + time = request.time, + exam = exam, + totalQuantity = request.answers.size, + ), + ) + + private fun getOrCreateExam( + request: GradeExamRequest, + memberId: UUID, + member: Member + ) = if (request.workbookId != null) { + val workbook = findWorkbook(request.workbookId) + isWorkbookOwner(workbook, memberId) + + examRepository.save( + Exam + .createExam( + member, + workbook, + examRepository.findMaxSequenceByWorkbookId(workbook) + 1, + ), + ) + } else { + ( + examRepository + .findById( + checkNotNull(request.examId), + ).orElseThrow { NotFoundException(fieldName = "exam") } + as SharedExam + ).apply { + validateSubmissionTime() + increaseExamineeCount() + } + } + + private fun isWorkbookOwner( + workbook: Workbook, + memberId: UUID, + ) { + if (workbook.member.id != memberId) { + throw BadRequestException(fieldName = "member", "사용자가 소유한 시험이 아닙니다.") + } + } } diff --git a/src/main/kotlin/com/swm_standard/phote/service/QuestionService.kt b/src/main/kotlin/com/swm_standard/phote/service/QuestionService.kt index 5871197..f390388 100644 --- a/src/main/kotlin/com/swm_standard/phote/service/QuestionService.kt +++ b/src/main/kotlin/com/swm_standard/phote/service/QuestionService.kt @@ -18,9 +18,9 @@ import com.swm_standard.phote.repository.MemberRepository import com.swm_standard.phote.repository.TagRepository import com.swm_standard.phote.repository.questionrepository.QuestionRepository import kotlinx.coroutines.Deferred -import kotlinx.coroutines.withContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.withContext import org.springframework.beans.factory.annotation.Value import org.springframework.http.HttpHeaders import org.springframework.http.MediaType diff --git a/src/test/kotlin/com/swm_standard/phote/entity/QuestionSetTest.kt b/src/test/kotlin/com/swm_standard/phote/entity/QuestionSetTest.kt index ffb5b5b..ce65305 100644 --- a/src/test/kotlin/com/swm_standard/phote/entity/QuestionSetTest.kt +++ b/src/test/kotlin/com/swm_standard/phote/entity/QuestionSetTest.kt @@ -20,7 +20,6 @@ class QuestionSetTest { fun `questionSet의 순서를 변경하는데 성공한다`() { val questionSet: QuestionSet = fixtureMonkey.giveMeOne() val newSequence = Arbitraries.integers().sample() - questionSet.updateSequence(newSequence) assertEquals(newSequence, questionSet.sequence)